From 1b0223f20f551754d9669b57796f7dcdb1ce4379 Mon Sep 17 00:00:00 2001 From: Stephen Hosom Date: Wed, 2 Sep 2026 17:21:39 -0400 Subject: [PATCH 1/6] Add CI-ready entitlement smart diff Export deterministic desired memberships and compare PR base/head without provider reconciliation. Report incomplete results when arbitrary Ruby groups prevent a complete offline calculation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b96e14a4-88bb-45b9-9b94-a681a34d05bc --- .gitignore | 1 + Gemfile.lock | 2 +- README.md | 20 ++ bin/entitlements-smart-diff | 6 + entitlements-app.gemspec | 4 +- lib/entitlements.rb | 35 ++++ lib/entitlements/data/groups/calculated.rb | 56 ++++-- .../data/groups/calculated/base.rb | 10 +- .../groups/calculated/modifiers/expiration.rb | 2 +- .../data/groups/calculated/ruby.rb | 33 +++- .../data/groups/calculated/rules/group.rb | 5 + .../data/groups/calculated/text.rb | 2 +- lib/entitlements/desired_groups.rb | 129 +++++++++++++ .../extras/ldap_group/rules/ldap_group.rb | 10 + lib/entitlements/smart_diff.rb | 181 ++++++++++++++++++ lib/entitlements/smart_diff/cli.rb | 65 +++++++ lib/version.rb | 2 +- spec/unit/entitlements/desired_groups_spec.rb | 165 ++++++++++++++++ .../ldap_group/rules/ldap_group_spec.rb | 16 ++ spec/unit/entitlements/smart_diff_spec.rb | 123 ++++++++++++ spec/unit/fixtures/dynamic-groups/config.yaml | 16 ++ .../dynamic-groups/groups/teams/dependent.txt | 2 + .../dynamic-groups/groups/teams/dynamic.rb | 16 ++ .../groups/teams/static-ruby.rb | 16 ++ .../dynamic-groups/groups/teams/static.txt | 2 + spec/unit/fixtures/smart-diff/config.yaml | 23 +++ .../groups/internal/contractors.txt | 2 + .../smart-diff/groups/internal/engineers.txt | 3 + .../smart-diff/groups/teams/direct.txt | 2 + .../smart-diff/groups/teams/expiring.yaml | 7 + .../smart-diff/groups/teams/filtered.txt | 3 + .../smart-diff/groups/teams/nested.yaml | 4 + .../smart-diff/groups/teams/ruby-group.rb | 15 ++ spec/unit/fixtures/smart-diff/people.yaml | 7 + 34 files changed, 962 insertions(+), 23 deletions(-) create mode 100755 bin/entitlements-smart-diff create mode 100644 lib/entitlements/desired_groups.rb create mode 100644 lib/entitlements/smart_diff.rb create mode 100644 lib/entitlements/smart_diff/cli.rb create mode 100644 spec/unit/entitlements/desired_groups_spec.rb create mode 100644 spec/unit/entitlements/smart_diff_spec.rb create mode 100644 spec/unit/fixtures/dynamic-groups/config.yaml create mode 100644 spec/unit/fixtures/dynamic-groups/groups/teams/dependent.txt create mode 100644 spec/unit/fixtures/dynamic-groups/groups/teams/dynamic.rb create mode 100644 spec/unit/fixtures/dynamic-groups/groups/teams/static-ruby.rb create mode 100644 spec/unit/fixtures/dynamic-groups/groups/teams/static.txt create mode 100644 spec/unit/fixtures/smart-diff/config.yaml create mode 100644 spec/unit/fixtures/smart-diff/groups/internal/contractors.txt create mode 100644 spec/unit/fixtures/smart-diff/groups/internal/engineers.txt create mode 100644 spec/unit/fixtures/smart-diff/groups/teams/direct.txt create mode 100644 spec/unit/fixtures/smart-diff/groups/teams/expiring.yaml create mode 100644 spec/unit/fixtures/smart-diff/groups/teams/filtered.txt create mode 100644 spec/unit/fixtures/smart-diff/groups/teams/nested.yaml create mode 100644 spec/unit/fixtures/smart-diff/groups/teams/ruby-group.rb create mode 100644 spec/unit/fixtures/smart-diff/people.yaml diff --git a/.gitignore b/.gitignore index f3949cc..01ac342 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Ignore binstubs but do commit the one specific for this code. bin/* !bin/deploy-entitlements +!bin/entitlements-smart-diff # There's a place for local caching of container gems to make local builds faster. # Keep the .keep file but not the gems themselves diff --git a/Gemfile.lock b/Gemfile.lock index a9787f3..ab5f2c9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - entitlements-app (1.2.1) + entitlements-app (1.2.2) concurrent-ruby (~> 1.3, >= 1.3.1) faraday (~> 2.0) logger (~> 1.6) diff --git a/README.md b/README.md index 69d067c..6c3d1ba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,25 @@ # entitlements-app +## Smart diff for CI + +`entitlements-smart-diff` compares complete desired entitlement-group membership from two prepared source trees. It uses one explicit people snapshot and evaluation timestamp for both trees, does not read current provider state, and writes complete JSON plus bounded Markdown: + +```shell +bundle exec entitlements-smart-diff \ + --base-tree /work/base \ + --head-tree /work/head \ + --base-sha "$BASE_SHA" \ + --head-sha "$HEAD_SHA" \ + --people-snapshot /inputs/people.yaml \ + --evaluated-at 2026-09-02T19:58:54Z \ + --json /output/smart-diff.json \ + --markdown /output/smart-diff.md +``` + +The default configuration path in each tree is `config/entitlements.yaml`; use `--base-config` and `--head-config` when repositories use another path. When the configuration uses plugin-defined backend types, preload the trusted plugin assembly with `RUBYOPT=-r/path/to/entitlements-and-plugins`. PR-controlled entitlement code must still run in a credential-free, network-isolated sandbox.-app + +Ruby entitlement groups are treated as dynamic by smart diff because arbitrary Ruby cannot be proven deterministic from frozen inputs. Groups that use or transitively depend on Ruby definitions are omitted from the membership comparison, and both JSON and Markdown report that the result is incomplete. Normal deployment behavior is unchanged.-app + [![acceptance](https://github.com/github/entitlements-app/actions/workflows/acceptance.yml/badge.svg)](https://github.com/github/entitlements-app/actions/workflows/acceptance.yml) [![test](https://github.com/github/entitlements-app/actions/workflows/test.yml/badge.svg)](https://github.com/github/entitlements-app/actions/workflows/test.yml) [![lint](https://github.com/github/entitlements-app/actions/workflows/lint.yml/badge.svg)](https://github.com/github/entitlements-app/actions/workflows/lint.yml) [![build](https://github.com/github/entitlements-app/actions/workflows/build.yml/badge.svg)](https://github.com/github/entitlements-app/actions/workflows/build.yml) [![release](https://github.com/github/entitlements-app/actions/workflows/release.yml/badge.svg)](https://github.com/github/entitlements-app/actions/workflows/release.yml) [![codeql](https://github.com/github/entitlements-app/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/github/entitlements-app/actions/workflows/codeql-analysis.yml) [![coverage](https://img.shields.io/badge/coverage-100%25-success)](https://img.shields.io/badge/coverage-100%25-success) [![style](https://img.shields.io/badge/code%20style-rubocop--github-blue)](https://github.com/github/rubocop-github) `entitlements-app` is a Ruby gem which provides git-managed LDAP group configuration and access provisioning to your declared resources. It powers Entitlements, GitHub's internal Identity and Access Management (IAM) system. Entitlements is a pluggable system designed to alleviate IAM pain points. diff --git a/bin/entitlements-smart-diff b/bin/entitlements-smart-diff new file mode 100755 index 0000000..39e015e --- /dev/null +++ b/bin/entitlements-smart-diff @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require "entitlements" +require "entitlements/smart_diff/cli" + +exit Entitlements::SmartDiff::Cli.run diff --git a/entitlements-app.gemspec b/entitlements-app.gemspec index 717eda0..0060bd7 100644 --- a/entitlements-app.gemspec +++ b/entitlements-app.gemspec @@ -10,9 +10,9 @@ Gem::Specification.new do |s| s.authors = ["GitHub, Inc. Security Ops"] s.email = "opensource+entitlements-app@github.com" s.license = "MIT" - s.files = Dir.glob("lib/**/*") + %w[bin/deploy-entitlements] + s.files = Dir.glob("lib/**/*") + %w[bin/deploy-entitlements bin/entitlements-smart-diff] s.homepage = "https://github.com/github/entitlements-app" - s.executables = %w[deploy-entitlements] + s.executables = %w[deploy-entitlements entitlements-smart-diff] s.required_ruby_version = ">= 3.0.0" diff --git a/lib/entitlements.rb b/lib/entitlements.rb index 6e89811..565383f 100644 --- a/lib/entitlements.rb +++ b/lib/entitlements.rb @@ -4,6 +4,7 @@ # Load third party dependencies first. require "concurrent" require "ruby_version_check" +require "time" # contracts.ruby has two specific ruby-version specific libraries, which we have vendored into lib/ @@ -88,12 +89,44 @@ def self.reset! @config = nil @config_file = nil @config_path_override = nil + @evaluation_time = nil @person_extra_methods = {} reset_extras! + reset_rule_classes! Entitlements::Data::Groups::Calculated.reset! end + # Remove classes loaded from Ruby entitlement files so separate evaluations cannot + # retain class-level descriptions, filters, metadata, or methods. + # + # Takes no arguments. + def self.reset_rule_classes! + return unless const_defined?(:Rule, false) + + Entitlements::Rule.constants(false).each do |constant| + Entitlements::Rule.send(:remove_const, constant) unless constant == :Base + end + end + + # Return the time used for date-sensitive entitlement evaluation. + # + # Returns a Time. + Contract C::None => Time + def self.evaluation_time + @evaluation_time || Time.now + end + + # Set the time used for date-sensitive entitlement evaluation. + # + # value - A Time. + # + # Returns the supplied Time. + Contract Time => Time + def self.evaluation_time=(value) + @evaluation_time = value + end + def self.reset_extras! extras_loaded = @extras_loaded if extras_loaded @@ -600,6 +633,7 @@ def self.cache require_relative "entitlements/cli" require_relative "entitlements/data/groups" require_relative "entitlements/data/people" +require_relative "entitlements/desired_groups" require_relative "entitlements/extras" require_relative "entitlements/extras/base" require_relative "entitlements/models/action" @@ -611,6 +645,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..cacecff 100644 --- a/lib/entitlements/data/groups/calculated.rb +++ b/lib/entitlements/data/groups/calculated.rb @@ -16,6 +16,8 @@ class Calculated include ::Contracts::Core C = ::Contracts + class DynamicGroupError < RuntimeError; end + FILE_EXTENSIONS = { "rb" => "Entitlements::Data::Groups::Calculated::Ruby", "txt" => "Entitlements::Data::Groups::Calculated::Text", @@ -39,6 +41,7 @@ def self.reset! @groups_in_ou_cache = {} @groups_cache = {} @config_cache = {} + Entitlements::Data::Groups::Calculated::Rules::Group.reset! end # Construct a group object. @@ -60,10 +63,11 @@ def self.read(dn) # # Returns a Set of Strings (DNs) of the groups in this OU. Contract String, C::HashOf[String => C::Any], C::KeywordArgs[ - skip_broken_references: C::Optional[C::Bool] + skip_broken_references: C::Optional[C::Bool], + skip_dynamic_groups: C::Optional[C::Bool] ] => C::SetOf[String] - def self.read_all(ou_key, cfg_obj, skip_broken_references: false) - return read_mirror(ou_key, cfg_obj) if cfg_obj["mirror"] + def self.read_all(ou_key, cfg_obj, skip_broken_references: false, skip_dynamic_groups: false) + return read_mirror(ou_key, cfg_obj, skip_dynamic_groups: skip_dynamic_groups) if cfg_obj["mirror"] @config_cache[ou_key] ||= cfg_obj @groups_in_ou_cache[ou_key] ||= begin @@ -94,15 +98,28 @@ def self.read_all(ou_key, cfg_obj, skip_broken_references: false) group_dn = ["cn=#{file_without_extension}", cfg_obj.fetch("base")].join(",") # Use the ruleset to build the group. - options = { skip_broken_references: skip_broken_references } - - Entitlements.cache[:file_objects][filename] ||= ruleset(filename: filename, config: cfg_obj, options: options) - @groups_cache[group_dn] = Entitlements::Models::Group.new( - dn: group_dn, - members: Entitlements.cache[:file_objects][filename].modified_filtered_members, - description: Entitlements.cache[:file_objects][filename].description, - metadata: Entitlements.cache[:file_objects][filename].metadata.merge("_filename" => filename) - ) + options = { + skip_broken_references: skip_broken_references, + skip_dynamic_groups: skip_dynamic_groups + } + + begin + Entitlements.cache[:file_objects][filename] ||= ruleset(filename: filename, config: cfg_obj, options: options) + @groups_cache[group_dn] = Entitlements::Models::Group.new( + dn: group_dn, + members: Entitlements.cache[:file_objects][filename].modified_filtered_members, + description: Entitlements.cache[:file_objects][filename].description, + metadata: Entitlements.cache[:file_objects][filename].metadata.merge("_filename" => filename) + ) + rescue DynamicGroupError => e + raise unless skip_dynamic_groups + + entitlement_group = "#{ou_key}/#{file_without_extension}" + Entitlements.cache[:dynamic_group_warnings] ||= {} + Entitlements.cache[:dynamic_group_warnings][entitlement_group] = e.message + Entitlements.logger.warn "Skipping #{entitlement_group}: #{e.message}" + next + end result.add group_dn end @@ -152,8 +169,10 @@ def self.all_groups # cfg_obj - Hash with the configuration for that key from the configuration file. # # Returns a Set of Strings (DNs) of the groups in this OU. - Contract String, C::HashOf[String => C::Any] => C::SetOf[String] - def self.read_mirror(ou_key, cfg_obj) + Contract String, C::HashOf[String => C::Any], C::KeywordArgs[ + skip_dynamic_groups: C::Optional[C::Bool] + ] => C::SetOf[String] + def self.read_mirror(ou_key, cfg_obj, skip_dynamic_groups: false) @groups_in_ou_cache[ou_key] ||= begin Entitlements.logger.debug "Mirroring #{ou_key} from #{cfg_obj['mirror']}" @@ -162,6 +181,15 @@ def self.read_mirror(ou_key, cfg_obj) end result = Set.new + if skip_dynamic_groups + source_prefix = "#{cfg_obj['mirror']}/" + Entitlements.cache.fetch(:dynamic_group_warnings, {}).to_a.each do |entitlement_group, message| + next unless entitlement_group.start_with?(source_prefix) + + mirror_group = "#{ou_key}/#{entitlement_group.delete_prefix(source_prefix)}" + Entitlements.cache[:dynamic_group_warnings][mirror_group] = message + end + end @groups_in_ou_cache[cfg_obj["mirror"]].each do |source_dn| source_group = @groups_cache[source_dn] unless source_group diff --git a/lib/entitlements/data/groups/calculated/base.rb b/lib/entitlements/data/groups/calculated/base.rb index d19d08b..383c0df 100644 --- a/lib/entitlements/data/groups/calculated/base.rb +++ b/lib/entitlements/data/groups/calculated/base.rb @@ -217,7 +217,7 @@ def expired?(expiration, context) return false if expiration.nil? || expiration.strip.empty? if expiration =~ /\A(\d{4})-(\d{2})-(\d{2})\z/ year, month, day = Regexp.last_match(1).to_i, Regexp.last_match(2).to_i, Regexp.last_match(3).to_i - return Time.utc(year, month, day, 0, 0, 0) <= Time.now.utc + return Time.utc(year, month, day, 0, 0, 0) <= Entitlements.evaluation_time.utc end message = "Invalid expiration date #{expiration.inspect} in #{context} (expected format: YYYY-MM-DD)" raise ArgumentError, message @@ -243,7 +243,13 @@ def members_from_rules(rule) Entitlements.cache[:dependencies] << "#{rou}/#{cn}" # Actually calculate it. - Entitlements.cache[:calculated][rou][cn] = _members_from_rules(rule) + begin + Entitlements.cache[:calculated][rou][cn] = _members_from_rules(rule) + rescue Entitlements::Data::Groups::Calculated::DynamicGroupError + Entitlements.cache[:calculated][rou].delete(cn) + Entitlements.cache[:dependencies].delete("#{rou}/#{cn}") + raise + end # This should be the last item on the dependencies array, so pop it off. unless Entitlements.cache[:dependencies].last == "#{rou}/#{cn}" diff --git a/lib/entitlements/data/groups/calculated/modifiers/expiration.rb b/lib/entitlements/data/groups/calculated/modifiers/expiration.rb index 4b57c7b..2436a4f 100644 --- a/lib/entitlements/data/groups/calculated/modifiers/expiration.rb +++ b/lib/entitlements/data/groups/calculated/modifiers/expiration.rb @@ -30,7 +30,7 @@ def modify(result) end # If the date is in the future, leave the entitlement unchanged. - return false if parse_date > Time.now.utc.to_date + return false if parse_date > Entitlements.evaluation_time.utc.to_date # Empty the group. Set metadata allowing no members. Return true to indicate modification. rs.metadata["no_members_ok"] = true diff --git a/lib/entitlements/data/groups/calculated/ruby.rb b/lib/entitlements/data/groups/calculated/ruby.rb index e39a60c..d43e161 100644 --- a/lib/entitlements/data/groups/calculated/ruby.rb +++ b/lib/entitlements/data/groups/calculated/ruby.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true # Interact with rules that are stored as ruby code. +require "ripper" + module Entitlements class Data class Groups @@ -101,7 +103,13 @@ def initialize_metadata Contract C::None => Object def rule_obj @rule_obj ||= begin - require filename + reasons = dynamic_reasons + if options[:skip_dynamic_groups] && reasons.any? + raise Entitlements::Data::Groups::Calculated::DynamicGroupError, + "Dynamic group #{dynamic_group_identifier} uses #{reasons.join(' and ')}" + end + + load filename clazz = Kernel.const_get(ruby_class_name) clazz.new end @@ -130,6 +138,29 @@ def raise_rule_exception(exc) def ruby_class_name ["Entitlements", "Rule", ou, cn].map { |x| camelize(x) }.join("::") end + + def dynamic_group_identifier + source_directory = File.expand_path(File.dirname(filename)) + group_name = Entitlements.config.fetch("groups").filter_map do |name, config| + directory = config["dir"] || name + path = directory.start_with?("/") ? directory : File.expand_path(directory, Entitlements.config_path) + name if File.expand_path(path) == source_directory + end.min_by(&:length) + "#{group_name || rou}/#{cn}" + end + + def dynamic_reasons + constants = Ripper.lex(File.read(filename)).filter_map do |_position, type, token, _state| + token if type == :on_const + end + reasons = ["arbitrary Ruby code"] + reasons << "environment variables" if constants.include?("ENV") + reasons << "network client" if (constants & %w[Octokit Faraday HTTP]).any? + if constants.include?("GitHub") && constants.include?("Service") + reasons << "live GitHub service" + end + reasons + end end end end diff --git a/lib/entitlements/data/groups/calculated/rules/group.rb b/lib/entitlements/data/groups/calculated/rules/group.rb index c74f4d2..db8714f 100644 --- a/lib/entitlements/data/groups/calculated/rules/group.rb +++ b/lib/entitlements/data/groups/calculated/rules/group.rb @@ -14,6 +14,10 @@ class Group < Entitlements::Data::Groups::Calculated::Rules::Base "yaml" => "Entitlements::Data::Groups::Calculated::YAML" } + def self.reset! + @files_for_cache = {} + end + # Interface method: Get a Set[Entitlements::Models::Person] matching this condition. # # value - The value to match. @@ -66,6 +70,7 @@ def self.matches(value:, filename: nil, options: {}) clazz = Kernel.const_get(FILE_EXTENSIONS[ext]) Entitlements.cache[:file_objects][filebase_with_path] = clazz.new( filename: "#{filebase_with_path}.#{ext}", + options: options ) if Entitlements.cache[:file_objects][filebase_with_path].members == :calculating next if matching_files.size > 1 diff --git a/lib/entitlements/data/groups/calculated/text.rb b/lib/entitlements/data/groups/calculated/text.rb index b78828a..14a6c0e 100644 --- a/lib/entitlements/data/groups/calculated/text.rb +++ b/lib/entitlements/data/groups/calculated/text.rb @@ -182,7 +182,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/desired_groups.rb b/lib/entitlements/desired_groups.rb new file mode 100644 index 0000000..e3378cc --- /dev/null +++ b/lib/entitlements/desired_groups.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "digest" +require "json" + +module Entitlements + class DesiredGroups + SCHEMA_VERSION = 1 + + def self.export(config_file:, source_sha:, people_source:, evaluated_at:, tree_root: nil, allow_incomplete: false) + validate_inputs!( + config_file: config_file, + source_sha: source_sha, + people_source: people_source, + evaluated_at: evaluated_at + ) + + evaluation_time = parse_time(evaluated_at) + people_hash = Digest::SHA256.file(people_source).hexdigest + original_dir = ENV["DIR"] + ENV["DIR"] = File.expand_path(tree_root) if tree_root + + Entitlements.reset! + Entitlements.config_file = config_file + backend_identifiers = backend_identifiers(Entitlements.config) + use_people_snapshot!(people_source) + Entitlements.validate_configuration_file! + Entitlements.evaluation_time = evaluation_time + Entitlements.load_extras if Entitlements.config.key?("extras") + Entitlements.prefetch_people + Entitlements.cache[:desired_groups_export] = true + Entitlements.register_filters if Entitlements.config.key?("filters") + + memberships = export_memberships(backend_identifiers, allow_incomplete: allow_incomplete) + warnings = Entitlements.cache.fetch(:dynamic_group_warnings, {}).sort.map do |entitlement_group, message| + {"entitlement_group" => entitlement_group, "message" => message} + end + { + "schema_version" => SCHEMA_VERSION, + "source_sha" => source_sha.downcase, + "people_snapshot_sha256" => people_hash, + "evaluated_at" => evaluation_time.utc.iso8601, + "complete" => warnings.empty?, + "warnings" => warnings, + "memberships" => memberships + } + ensure + Entitlements.reset! + if tree_root + original_dir ? ENV["DIR"] = original_dir : ENV.delete("DIR") + end + end + + def self.export_json(**args) + JSON.pretty_generate(export(**args)) << "\n" + end + + def self.validate_inputs!(config_file:, source_sha:, people_source:, evaluated_at:) + raise ArgumentError, "config_file must be a readable file" unless File.file?(config_file) && File.readable?(config_file) + raise ArgumentError, "people_source must be a readable file" unless File.file?(people_source) && File.readable?(people_source) + raise ArgumentError, "source_sha must be a commit SHA" unless source_sha.is_a?(String) && source_sha.match?(/\A[0-9a-f]{7,64}\z/i) + + parse_time(evaluated_at) + end + private_class_method :validate_inputs! + + def self.parse_time(value) + parsed = value.is_a?(Time) ? value : Time.iso8601(value.to_s) + raise ArgumentError, "evaluated_at must include a timezone" if !value.is_a?(Time) && value.to_s !~ /(Z|[+-]\d{2}:\d{2})\z/ + parsed + rescue ArgumentError + raise ArgumentError, "evaluated_at must be an ISO 8601 timestamp with a timezone" + end + private_class_method :parse_time + + def self.backend_identifiers(config) + config.fetch("groups").to_h do |group_name, group_config| + identifier = group_config["backend"] || group_config["type"] + unless identifier.is_a?(String) && !identifier.empty? + raise ArgumentError, "Group #{group_name.inspect} has no stable backend identifier" + end + [group_name, identifier] + end + end + private_class_method :backend_identifiers + + def self.use_people_snapshot!(people_source) + Entitlements.config["people"] = { + "smart_diff" => { + "type" => "yaml", + "config" => {"filename" => File.expand_path(people_source)} + } + } + Entitlements.config["people_data_source"] = "smart_diff" + end + private_class_method :use_people_snapshot! + + def self.export_memberships(backend_identifiers, allow_incomplete:) + records = {} + exportable_groups.each do |group_name, group_config| + Entitlements::Data::Groups::Calculated.read_all( + group_name, + group_config, + skip_dynamic_groups: allow_incomplete + ).each do |group_dn| + group = Entitlements::Data::Groups::Calculated.read(group_dn) + group.member_strings.each do |username| + record = { + "backend" => backend_identifiers.fetch(group_name), + "entitlement_group" => "#{group_name}/#{group.cn}", + "username" => username.downcase + } + records[record.values_at("backend", "entitlement_group", "username")] = record + end + end + end + records.values.sort_by { |record| record.values_at("backend", "entitlement_group", "username") } + end + private_class_method :export_memberships + + def self.exportable_groups + Entitlements.config.fetch("groups").select { |_name, config| config.key?("base") }.sort_by do |group_name, config| + backend = Entitlements.backends.fetch(config.fetch("type")) + [backend.fetch(:priority), config.key?("mirror") ? 1 : 0, group_name.length, group_name] + end + end + private_class_method :exportable_groups + end +end diff --git a/lib/entitlements/extras/ldap_group/rules/ldap_group.rb b/lib/entitlements/extras/ldap_group/rules/ldap_group.rb index 9dd72e9..e906364 100644 --- a/lib/entitlements/extras/ldap_group/rules/ldap_group.rb +++ b/lib/entitlements/extras/ldap_group/rules/ldap_group.rb @@ -22,6 +22,16 @@ class LDAPGroup < Entitlements::Data::Groups::Calculated::Rules::Base options: C::Optional[C::HashOf[Symbol => C::Any]] ] => C::SetOf[Entitlements::Models::Person] def self.matches(value:, filename: nil, options: {}) + if Entitlements.cache[:desired_groups_export] + return Set.new(Entitlements.cache[:people_obj].read.values.select do |person| + begin + Array(person["shellentitlements"]).map(&:downcase).include?(value.downcase) + rescue KeyError + false + end + end) + end + Entitlements.cache[:ldap_cache] ||= {} Entitlements.cache[:ldap_cache][value] ||= begin entry = ldap.read(value) diff --git a/lib/entitlements/smart_diff.rb b/lib/entitlements/smart_diff.rb new file mode 100644 index 0000000..429351d --- /dev/null +++ b/lib/entitlements/smart_diff.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require "cgi" +require "json" +require "set" + +module Entitlements + class SmartDiff + SCHEMA_VERSION = 1 + DEFAULT_MARKDOWN_LIMIT = 200 + LIMITATION = "This compares desired entitlement-group membership. It does not predict provider-specific roles, " \ + "resource mappings, drift, invitations, JIT sessions, or API operations." + + def self.run(base_config:, head_config:, base_sha:, head_sha:, people_source:, evaluated_at:, base_tree: nil, head_tree: nil, markdown_limit: DEFAULT_MARKDOWN_LIMIT) + common = {people_source: people_source, evaluated_at: evaluated_at} + base = Entitlements::DesiredGroups.export( + config_file: base_config, + source_sha: base_sha, + tree_root: base_tree, + allow_incomplete: true, + **common + ) + head = Entitlements::DesiredGroups.export( + config_file: head_config, + source_sha: head_sha, + tree_root: head_tree, + allow_incomplete: true, + **common + ) + compare(base: base, head: head, markdown_limit: markdown_limit) + end + + def self.compare(base:, head:, markdown_limit: DEFAULT_MARKDOWN_LIMIT) + validate_snapshot!(base, "base") + validate_snapshot!(head, "head") + raise ArgumentError, "Base and head used different people snapshots" unless base["people_snapshot_sha256"] == head["people_snapshot_sha256"] + raise ArgumentError, "Base and head used different evaluation timestamps" unless base["evaluated_at"] == head["evaluated_at"] + raise ArgumentError, "markdown_limit must be a positive integer" unless markdown_limit.is_a?(Integer) && markdown_limit.positive? + + base_memberships = indexed_memberships(base) + head_memberships = indexed_memberships(head) + incomplete_groups = Set.new((snapshot_warnings(base) + snapshot_warnings(head)).map { |warning| warning.fetch("entitlement_group") }) + base_memberships.delete_if { |identity, _record| incomplete_groups.include?(identity[1]) } + head_memberships.delete_if { |identity, _record| incomplete_groups.include?(identity[1]) } + gains = (head_memberships.keys - base_memberships.keys).sort.map { |identity| head_memberships.fetch(identity) } + losses = (base_memberships.keys - head_memberships.keys).sort.map { |identity| base_memberships.fetch(identity) } + + result = { + "schema_version" => SCHEMA_VERSION, + "complete" => snapshot_complete?(base) && snapshot_complete?(head), + "base" => snapshot_metadata(base), + "head" => snapshot_metadata(head), + "warnings" => {"base" => snapshot_warnings(base), "head" => snapshot_warnings(head)}, + "counts" => {"gains" => gains.length, "losses" => losses.length}, + "gains" => gains, + "losses" => losses + } + [result, markdown(result, limit: markdown_limit)] + end + + def self.json(result) + JSON.pretty_generate(result) << "\n" + end + + def self.markdown(result, limit: DEFAULT_MARKDOWN_LIMIT) + lines = [ + "## Proposed entitlement membership changes", + ] + unless result.fetch("complete") + warning_count = result.fetch("warnings").values.flatten.map { |warning| warning.fetch("entitlement_group") }.uniq.length + lines.concat([ + "", + "> [!WARNING]", + "> This diff is incomplete. #{membership_count(warning_count).sub('membership', 'group')} skipped because calculation depends on dynamic inputs.", + ]) + end + lines.concat([ + "", + "**#{membership_count(result.fetch('counts').fetch('gains'))} added; " \ + "#{membership_count(result.fetch('counts').fetch('losses'))} removed.**", + "", + "Base: `#{escape_inline(result.fetch('base').fetch('source_sha'))}` ", + "Head: `#{escape_inline(result.fetch('head').fetch('source_sha'))}`", + "" + ]) + + remaining = limit + [["Added", "gains"], ["Removed", "losses"]].each do |heading, key| + records = result.fetch(key) + lines.concat(["### #{heading}", ""]) + if records.empty? + lines.concat(["None.", ""]) + next + end + + visible = records.first(remaining) + lines.concat(["| User | Backend | Entitlement group |", "|---|---|---|"]) + visible.each do |record| + lines << "| #{escape_table(record.fetch('username'))} | #{escape_table(record.fetch('backend'))} | " \ + "#{escape_table(record.fetch('entitlement_group'))} |" + end + lines << "" + remaining -= visible.length + omitted = records.length - visible.length + lines.concat(["_#{omitted} additional #{heading.downcase} memberships omitted; see the JSON artifact._", ""]) if omitted.positive? + end + + lines.concat(["> #{LIMITATION}", ""]) + unless result.fetch("complete") + warnings = result.fetch("warnings").flat_map do |tree, entries| + entries.map { |warning| [tree, warning] } + end.sort_by { |tree, warning| [tree, warning.fetch("entitlement_group")] } + visible_warnings = warnings.first(remaining) + lines.concat(["### Incomplete groups", ""]) + if visible_warnings.any? + lines.concat(["| Tree | Entitlement group | Reason |", "|---|---|---|"]) + visible_warnings.each do |tree, warning| + lines << "| #{tree} | #{escape_table(warning.fetch('entitlement_group'))} | #{escape_table(warning.fetch('message'))} |" + end + lines << "" + end + omitted = warnings.length - visible_warnings.length + if omitted.positive? + lines.concat(["_#{omitted} additional incomplete-group warnings omitted; see the JSON artifact._", ""]) + end + end + lines.join("\n") + end + + def self.validate_snapshot!(snapshot, label) + raise ArgumentError, "#{label} snapshot must be a hash" unless snapshot.is_a?(Hash) + raise ArgumentError, "#{label} snapshot has an unsupported schema version" unless snapshot["schema_version"] == Entitlements::DesiredGroups::SCHEMA_VERSION + %w[source_sha people_snapshot_sha256 evaluated_at memberships].each do |key| + raise ArgumentError, "#{label} snapshot is missing #{key}" unless snapshot.key?(key) + end + raise ArgumentError, "#{label} memberships must be an array" unless snapshot["memberships"].is_a?(Array) + end + private_class_method :validate_snapshot! + + def self.indexed_memberships(snapshot) + snapshot.fetch("memberships").to_h do |record| + unless record.is_a?(Hash) && %w[backend entitlement_group username].all? { |key| record[key].is_a?(String) } + raise ArgumentError, "Invalid membership record: #{record.inspect}" + end + identity = record.values_at("backend", "entitlement_group", "username") + [identity, record] + end + end + private_class_method :indexed_memberships + + def self.snapshot_metadata(snapshot) + snapshot.slice("source_sha", "people_snapshot_sha256", "evaluated_at", "complete") + end + private_class_method :snapshot_metadata + + def self.escape_table(value) + CGI.escapeHTML(value.to_s.gsub(/[\r\n]+/, " ")).gsub("|", "\\|") + end + private_class_method :escape_table + + def self.escape_inline(value) + value.to_s.gsub("`", "\\`").gsub(/[\r\n]+/, " ") + end + private_class_method :escape_inline + + def self.membership_count(count) + "#{count} #{count == 1 ? 'membership' : 'memberships'}" + end + private_class_method :membership_count + + def self.snapshot_complete?(snapshot) + snapshot.fetch("complete", true) + end + private_class_method :snapshot_complete? + + def self.snapshot_warnings(snapshot) + snapshot.fetch("warnings", []) + end + private_class_method :snapshot_warnings + end +end diff --git a/lib/entitlements/smart_diff/cli.rb b/lib/entitlements/smart_diff/cli.rb new file mode 100644 index 0000000..c97ad00 --- /dev/null +++ b/lib/entitlements/smart_diff/cli.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "optparse" + +module Entitlements + class SmartDiff + class Cli + # :nocov: + DEFAULT_CONFIG = "config/entitlements.yaml" + + def self.run(argv = ARGV) + options = parse(argv) + result, markdown = Entitlements::SmartDiff.run( + base_config: config_path(options.fetch(:base_tree), options[:base_config]), + head_config: config_path(options.fetch(:head_tree), options[:head_config]), + base_sha: options.fetch(:base_sha), + head_sha: options.fetch(:head_sha), + people_source: options.fetch(:people_snapshot), + evaluated_at: options.fetch(:evaluated_at), + base_tree: options.fetch(:base_tree), + head_tree: options.fetch(:head_tree), + markdown_limit: options.fetch(:markdown_limit) + ) + File.write(options.fetch(:json), Entitlements::SmartDiff.json(result)) + File.write(options.fetch(:markdown), markdown) + 0 + rescue KeyError, OptionParser::ParseError, ArgumentError, SystemCallError => e + warn "entitlements-smart-diff: #{e.message}" + 1 + end + + def self.parse(argv) + options = {markdown_limit: Entitlements::SmartDiff::DEFAULT_MARKDOWN_LIMIT} + parser = OptionParser.new do |opts| + opts.banner = "Usage: entitlements-smart-diff [options]" + opts.on("--base-tree PATH") { |value| options[:base_tree] = value } + opts.on("--head-tree PATH") { |value| options[:head_tree] = value } + opts.on("--base-config PATH") { |value| options[:base_config] = value } + opts.on("--head-config PATH") { |value| options[:head_config] = value } + opts.on("--base-sha SHA") { |value| options[:base_sha] = value } + opts.on("--head-sha SHA") { |value| options[:head_sha] = value } + opts.on("--people-snapshot PATH") { |value| options[:people_snapshot] = value } + opts.on("--evaluated-at TIMESTAMP") { |value| options[:evaluated_at] = value } + opts.on("--json PATH") { |value| options[:json] = value } + opts.on("--markdown PATH") { |value| options[:markdown] = value } + opts.on("--markdown-limit COUNT", Integer) { |value| options[:markdown_limit] = value } + end + parser.parse!(argv) + required = %i[base_tree head_tree base_sha head_sha people_snapshot evaluated_at json markdown] + missing = required.reject { |key| options.key?(key) } + raise OptionParser::MissingArgument, missing.join(", ") if missing.any? + options + end + private_class_method :parse + + def self.config_path(tree, configured_path) + path = configured_path || DEFAULT_CONFIG + return path if path.start_with?("/") + File.expand_path(path, tree) + end + private_class_method :config_path + # :nocov: + end + end +end diff --git a/lib/version.rb b/lib/version.rb index 412c637..06191dc 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -2,6 +2,6 @@ module Entitlements module Version - VERSION = "1.2.1" + VERSION = "1.2.2" end end diff --git a/spec/unit/entitlements/desired_groups_spec.rb b/spec/unit/entitlements/desired_groups_spec.rb new file mode 100644 index 0000000..ec854b5 --- /dev/null +++ b/spec/unit/entitlements/desired_groups_spec.rb @@ -0,0 +1,165 @@ +# 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["complete"]).to be true + expect(first["warnings"]).to eq([]) + expect(first["memberships"]).to eq(first["memberships"].sort_by(&:values)) + expect(first["memberships"].length).to eq(10) + expect(first["memberships"]).to include( + {"backend" => "dummy", "entitlement_group" => "teams/direct", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams/nested", "username" => "bob"}, + {"backend" => "dummy", "entitlement_group" => "teams/ruby-group", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams_mirror/direct", "username" => "alice"} + ) + expect(first["memberships"]).not_to include( + {"backend" => "dummy", "entitlement_group" => "teams/expiring", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams/filtered", "username" => "contractor"} + ) + end + + it "serializes byte-for-byte deterministic JSON" do + expect(described_class.export_json(**args)).to eq(described_class.export_json(**args)) + expect(described_class.export_json(**args)).to end_with("\n") + end + + it "accepts a Time evaluation value" do + result = described_class.export(**args.merge(evaluated_at: Time.new(2026, 9, 1, 12, 0, 0, "-04:00"))) + expect(result["evaluated_at"]).to eq("2026-09-01T16:00:00Z") + expect(result["memberships"]).to include( + {"backend" => "dummy", "entitlement_group" => "teams/expiring", "username" => "alice"} + ) + end + + it "sets and restores the source tree environment for configuration ERB" do + original = ENV["DIR"] + result = described_class.export(**args.merge(tree_root: fixture("smart-diff"))) + expect(result["memberships"]).not_to be_empty + expect(ENV["DIR"]).to eq(original) + end + + it "rejects invalid inputs" do + expect { described_class.export(**args.merge(config_file: "missing")) }.to raise_error(ArgumentError, /config_file/) + expect { described_class.export(**args.merge(people_source: "missing")) }.to raise_error(ArgumentError, /people_source/) + expect { described_class.export(**args.merge(source_sha: "nope")) }.to raise_error(ArgumentError, /source_sha/) + expect { described_class.export(**args.merge(evaluated_at: "2026-09-02")) }.to raise_error(ArgumentError, /evaluated_at/) + end + + it "rejects groups without stable backend identifiers" do + allow(Entitlements).to receive(:config).and_return("groups" => {"teams" => {}}) + expect { described_class.export(**args) }.to raise_error(ArgumentError, /stable backend identifier/) + end + + it "can skip dynamic groups and report an incomplete snapshot" do + dynamic_args = args.merge(config_file: fixture("dynamic-groups/config.yaml")) + expect { described_class.export(**dynamic_args) } + .to raise_error(KeyError, /DYNAMIC_GROUP_TOKEN/) + + result = described_class.export(**dynamic_args.merge(allow_incomplete: true)) + expect(result["complete"]).to be false + expect(result["warnings"]).to eq([ + { + "entitlement_group" => "teams/dynamic", + "message" => "Dynamic group teams/dynamic uses arbitrary Ruby code and environment variables and network client and live GitHub service" + }, + { + "entitlement_group" => "teams/dependent", + "message" => "Dynamic group teams/dynamic uses arbitrary Ruby code and environment variables and network client and live GitHub service" + }, + { + "entitlement_group" => "teams/static-ruby", + "message" => "Dynamic group teams/static-ruby uses arbitrary Ruby code" + }, + { + "entitlement_group" => "teams_mirror/dynamic", + "message" => "Dynamic group teams/dynamic uses arbitrary Ruby code and environment variables and network client and live GitHub service" + }, + { + "entitlement_group" => "teams_mirror/dependent", + "message" => "Dynamic group teams/dynamic uses arbitrary Ruby code and environment variables and network client and live GitHub service" + }, + { + "entitlement_group" => "teams_mirror/static-ruby", + "message" => "Dynamic group teams/static-ruby uses arbitrary Ruby code" + } + ].sort_by { |warning| warning["entitlement_group"] }) + expect(result["memberships"]).to eq([ + {"backend" => "dummy", "entitlement_group" => "teams/static", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams_mirror/static", "username" => "alice"} + ]) + end + + it "does not leak Ruby rule class state between trees" do + Dir.mktmpdir do |directory| + FileUtils.cp_r(Dir.glob(File.join(fixture("smart-diff"), "*")), directory) + ruby_file = File.join(directory, "groups", "teams", "ruby-group.rb") + File.write(ruby_file, <<~RUBY) + module Entitlements + class Rule + class Teams + class RubyGroup < Entitlements::Rule::Base + filter "contractors" => :all + def members + Set.new([Entitlements.cache[:people_obj].read("contractor")]) + end + end + end + end + end + RUBY + base = described_class.export(**args.merge(config_file: File.join(directory, "config.yaml"))) + expect(base["memberships"]).to include( + {"backend" => "dummy", "entitlement_group" => "teams/ruby-group", "username" => "contractor"} + ) + + File.write(ruby_file, <<~RUBY) + module Entitlements + class Rule + class Teams + class RubyGroup < Entitlements::Rule::Base + def members + Set.new([Entitlements.cache[:people_obj].read("contractor")]) + end + end + end + end + end + RUBY + head = described_class.export(**args.merge(config_file: File.join(directory, "config.yaml"))) + expect(head["memberships"]).not_to include( + {"backend" => "dummy", "entitlement_group" => "teams/ruby-group", "username" => "contractor"} + ) + end + end +end diff --git a/spec/unit/entitlements/extras/ldap_group/rules/ldap_group_spec.rb b/spec/unit/entitlements/extras/ldap_group/rules/ldap_group_spec.rb index 9d13e4d..97f7f4b 100644 --- a/spec/unit/entitlements/extras/ldap_group/rules/ldap_group_spec.rb +++ b/spec/unit/entitlements/extras/ldap_group/rules/ldap_group_spec.rb @@ -21,6 +21,22 @@ let(:members) { %w[NEBELUNg russianblue oJosazuLEs].map { |uid| people_obj.read(uid) } } describe "#matches" do + context "during a desired-groups export" do + let(:people) do + { + "member" => Entitlements::Models::Person.new(uid: "member", attributes: {"shellentitlements" => [dn]}), + "other" => Entitlements::Models::Person.new(uid: "other", attributes: {}) + } + end + let(:people_obj) { Entitlements::Data::People::YAML.new(filename: fixture("people.yaml"), people: people) } + let(:cache) { { people_obj: people_obj, desired_groups_export: true } } + + it "uses frozen person attributes instead of LDAP" do + expect(described_class).not_to receive(:ldap) + expect(obj.members.map(&:uid)).to eq(["member"]) + end + end + context "for a group that was cached" do let(:ldap_cache) { { dn => group } } diff --git a/spec/unit/entitlements/smart_diff_spec.rb b/spec/unit/entitlements/smart_diff_spec.rb new file mode 100644 index 0000000..865b802 --- /dev/null +++ b/spec/unit/entitlements/smart_diff_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe Entitlements::SmartDiff do + let(:base) do + { + "schema_version" => 1, + "source_sha" => "a" * 40, + "people_snapshot_sha256" => "people", + "evaluated_at" => "2026-09-02T19:58:54Z", + "memberships" => [ + {"backend" => "dummy", "entitlement_group" => "teams/old", "username" => "alice"}, + {"backend" => "dummy", "entitlement_group" => "teams/same", "username" => "bob"} + ] + } + end + let(:head) do + { + "schema_version" => 1, + "source_sha" => "b" * 40, + "people_snapshot_sha256" => "people", + "evaluated_at" => "2026-09-02T19:58:54Z", + "memberships" => [ + {"backend" => "dummy", "entitlement_group" => "teams/new|group", "username" => ""}, + {"backend" => "dummy", "entitlement_group" => "teams/same", "username" => "bob"} + ] + } + end + + it "calculates gains and losses and renders safe bounded Markdown" do + result, markdown = described_class.compare(base: base, head: head) + + expect(result["counts"]).to eq("gains" => 1, "losses" => 1) + expect(result["gains"]).to eq([head["memberships"].first]) + expect(result["losses"]).to eq([base["memberships"].first]) + expect(result["base"]).not_to have_key("memberships") + expect(markdown).to include("1 membership added; 1 membership removed") + expect(markdown).to include("<alice>") + expect(markdown).to include("teams/new\\|group") + expect(markdown).to include(described_class::LIMITATION) + expect(described_class.json(result)).to end_with("\n") + end + + it "renders empty sections 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.scan("None.").length).to eq(2) + + 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("1 additional added memberships omitted") + end + + it "warns without failing when either snapshot is incomplete" do + incomplete_head = head.merge( + "complete" => false, + "warnings" => [{"entitlement_group" => "teams/dynamic", "message" => "uses a live GitHub service"}] + ) + result, markdown = described_class.compare(base: base, head: incomplete_head) + expect(result["complete"]).to be false + expect(result["warnings"]["head"]).to eq(incomplete_head["warnings"]) + expect(markdown).to include("[!WARNING]") + expect(markdown).to include("1 group skipped") + expect(markdown).to include("teams/dynamic") + end + + it "does not report changes for groups incomplete in either snapshot" do + incomplete_head = head.merge( + "complete" => false, + "memberships" => [head["memberships"].last], + "warnings" => [{"entitlement_group" => "teams/old", "message" => "dynamic"}] + ) + result, _markdown = described_class.compare(base: base, head: incomplete_head) + expect(result["losses"]).to be_empty + end + + it "bounds incomplete warning rows" do + warnings = 3.times.map do |index| + {"entitlement_group" => "teams/dynamic-#{index}", "message" => "dynamic"} + end + incomplete_head = head.merge("complete" => false, "warnings" => warnings) + _result, markdown = described_class.compare(base: base, head: incomplete_head, markdown_limit: 1) + expect(markdown.scan("| head |").length).to eq(0) + expect(markdown).to include("3 additional incomplete-group warnings omitted") + 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["base"]["people_snapshot_sha256"]).to eq(result["head"]["people_snapshot_sha256"]) + expect(result["base"]["evaluated_at"]).to eq(result["head"]["evaluated_at"]) + end + + it "rejects incomplete or inconsistent snapshots" do + expect { described_class.compare(base: [], head: head) }.to raise_error(ArgumentError, /must be a hash/) + expect { described_class.compare(base: base.merge("schema_version" => 2), head: head) }.to raise_error(ArgumentError, /schema version/) + expect { described_class.compare(base: base.reject { |key| key == "source_sha" }, head: head) }.to raise_error(ArgumentError, /missing source_sha/) + expect { described_class.compare(base: base.merge("memberships" => {}), head: head) }.to raise_error(ArgumentError, /must be an array/) + expect { described_class.compare(base: base, head: head.merge("people_snapshot_sha256" => "other")) }.to raise_error(ArgumentError, /people snapshots/) + expect { described_class.compare(base: base, head: head.merge("evaluated_at" => "other")) }.to raise_error(ArgumentError, /evaluation timestamps/) + expect { described_class.compare(base: base, head: head, markdown_limit: 0) }.to raise_error(ArgumentError, /markdown_limit/) + expect { described_class.compare(base: base.merge("memberships" => ["bad"]), head: head) }.to raise_error(ArgumentError, /Invalid membership/) + end +end diff --git a/spec/unit/fixtures/dynamic-groups/config.yaml b/spec/unit/fixtures/dynamic-groups/config.yaml new file mode 100644 index 0000000..d8deb63 --- /dev/null +++ b/spec/unit/fixtures/dynamic-groups/config.yaml @@ -0,0 +1,16 @@ +--- +configuration_path: ./groups +people: + original: + type: yaml + config: + filename: unavailable.yaml +people_data_source: original +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/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..628e40d --- /dev/null +++ b/spec/unit/fixtures/smart-diff/config.yaml @@ -0,0 +1,23 @@ +--- +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 + 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 From 533dec99e685aaeafc57adf5d5f670db8eed70e3 Mon Sep 17 00:00:00 2001 From: Stephen Hosom Date: Wed, 2 Sep 2026 17:31:20 -0400 Subject: [PATCH 2/6] Handle empty calculated AND rules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b96e14a4-88bb-45b9-9b94-a681a34d05bc --- lib/entitlements/data/groups/calculated/base.rb | 2 +- .../entitlements/data/groups/calculated/base_spec.rb | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/entitlements/data/groups/calculated/base.rb b/lib/entitlements/data/groups/calculated/base.rb index 383c0df..8881163 100644 --- a/lib/entitlements/data/groups/calculated/base.rb +++ b/lib/entitlements/data/groups/calculated/base.rb @@ -346,7 +346,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/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) } From 301e99da2c62076809853df471b8d02c4c8f65e3 Mon Sep 17 00:00:00 2001 From: Stephen Hosom Date: Wed, 2 Sep 2026 17:48:46 -0400 Subject: [PATCH 3/6] Clear dynamic group file state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b96e14a4-88bb-45b9-9b94-a681a34d05bc --- lib/entitlements/data/groups/calculated/base.rb | 1 + .../data/groups/calculated/base_spec.rb | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/entitlements/data/groups/calculated/base.rb b/lib/entitlements/data/groups/calculated/base.rb index 8881163..78d2dbf 100644 --- a/lib/entitlements/data/groups/calculated/base.rb +++ b/lib/entitlements/data/groups/calculated/base.rb @@ -248,6 +248,7 @@ def members_from_rules(rule) rescue Entitlements::Data::Groups::Calculated::DynamicGroupError Entitlements.cache[:calculated][rou].delete(cn) Entitlements.cache[:dependencies].delete("#{rou}/#{cn}") + Entitlements.cache.fetch(:file_objects, {}).delete(filename) raise end diff --git a/spec/unit/entitlements/data/groups/calculated/base_spec.rb b/spec/unit/entitlements/data/groups/calculated/base_spec.rb index 89fd5f2..f64af69 100644 --- a/spec/unit/entitlements/data/groups/calculated/base_spec.rb +++ b/spec/unit/entitlements/data/groups/calculated/base_spec.rb @@ -167,6 +167,21 @@ end end + context "when a dynamic dependency interrupts calculation" do + let(:file) { fixture("ldap-config/logic_tests/simple_and.yaml") } + let(:obj) { Entitlements::Data::Groups::Calculated::YAML.new(filename: file, config: config) } + + it "removes the partially calculated file object" do + Entitlements.cache[:file_objects] = {file => obj} + allow(obj).to receive(:_members_from_rules) + .and_raise(Entitlements::Data::Groups::Calculated::DynamicGroupError, "dynamic") + + expect { obj.send(:members_from_rules, {"always" => false}) } + .to raise_error(Entitlements::Data::Groups::Calculated::DynamicGroupError, "dynamic") + expect(Entitlements.cache[:file_objects]).not_to have_key(file) + end + 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) } From 91a5c4a84a9419ec5ea8d1b650bee7e76442526e Mon Sep 17 00:00:00 2001 From: Stephen Hosom Date: Wed, 2 Sep 2026 17:58:51 -0400 Subject: [PATCH 4/6] Do not cache in-progress group results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b96e14a4-88bb-45b9-9b94-a681a34d05bc --- lib/entitlements/data/groups/calculated/text.rb | 10 ++++++---- lib/entitlements/data/groups/calculated/yaml.rb | 10 ++++++---- .../entitlements/data/groups/calculated/text_spec.rb | 8 ++++++++ .../entitlements/data/groups/calculated/yaml_spec.rb | 10 ++++++++++ 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/lib/entitlements/data/groups/calculated/text.rb b/lib/entitlements/data/groups/calculated/text.rb index 14a6c0e..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. 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/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 From 77e8e53db35ffa75cf2227d4d9dcf49f0d47d356 Mon Sep 17 00:00:00 2001 From: Stephen Hosom Date: Wed, 2 Sep 2026 18:16:04 -0400 Subject: [PATCH 5/6] Escape backslashes in smart diff Markdown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b96e14a4-88bb-45b9-9b94-a681a34d05bc --- lib/entitlements/smart_diff.rb | 5 +++-- spec/unit/entitlements/smart_diff_spec.rb | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/entitlements/smart_diff.rb b/lib/entitlements/smart_diff.rb index 429351d..6316692 100644 --- a/lib/entitlements/smart_diff.rb +++ b/lib/entitlements/smart_diff.rb @@ -154,12 +154,13 @@ def self.snapshot_metadata(snapshot) private_class_method :snapshot_metadata def self.escape_table(value) - CGI.escapeHTML(value.to_s.gsub(/[\r\n]+/, " ")).gsub("|", "\\|") + escaped = value.to_s.gsub(/[\r\n]+/, " ").gsub("\\") { "\\\\" } + CGI.escapeHTML(escaped).gsub("|") { "\\|" } end private_class_method :escape_table def self.escape_inline(value) - value.to_s.gsub("`", "\\`").gsub(/[\r\n]+/, " ") + value.to_s.gsub(/[\r\n]+/, " ").gsub("\\") { "\\\\" }.gsub("`") { "\\`" } end private_class_method :escape_inline diff --git a/spec/unit/entitlements/smart_diff_spec.rb b/spec/unit/entitlements/smart_diff_spec.rb index 865b802..24c20b2 100644 --- a/spec/unit/entitlements/smart_diff_spec.rb +++ b/spec/unit/entitlements/smart_diff_spec.rb @@ -22,7 +22,7 @@ "people_snapshot_sha256" => "people", "evaluated_at" => "2026-09-02T19:58:54Z", "memberships" => [ - {"backend" => "dummy", "entitlement_group" => "teams/new|group", "username" => ""}, + {"backend" => "dummy", "entitlement_group" => "teams/new\\|group", "username" => ""}, {"backend" => "dummy", "entitlement_group" => "teams/same", "username" => "bob"} ] } @@ -37,7 +37,7 @@ 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("teams/new\\\\\\|group") expect(markdown).to include(described_class::LIMITATION) expect(described_class.json(result)).to end_with("\n") end From 3214867e15f843c0a6d20d5be51c5aa5ebca7e4e Mon Sep 17 00:00:00 2001 From: Stephen Hosom Date: Wed, 2 Sep 2026 19:17:02 -0400 Subject: [PATCH 6/6] Scope smart diff to affected groups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b96e14a4-88bb-45b9-9b94-a681a34d05bc --- lib/entitlements/smart_diff.rb | 34 ++++- lib/entitlements/smart_diff/scope.rb | 142 ++++++++++++++++++ .../entitlements/smart_diff/scope_spec.rb | 49 ++++++ spec/unit/entitlements/smart_diff_spec.rb | 12 ++ spec/unit/fixtures/smart-diff/config.yaml | 3 + 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 lib/entitlements/smart_diff/scope.rb create mode 100644 spec/unit/entitlements/smart_diff/scope_spec.rb diff --git a/lib/entitlements/smart_diff.rb b/lib/entitlements/smart_diff.rb index 6316692..5a36074 100644 --- a/lib/entitlements/smart_diff.rb +++ b/lib/entitlements/smart_diff.rb @@ -3,6 +3,7 @@ require "cgi" require "json" require "set" +require_relative "smart_diff/scope" module Entitlements class SmartDiff @@ -12,6 +13,14 @@ class SmartDiff "resource mappings, drift, invitations, JIT sessions, or API operations." def self.run(base_config:, head_config:, base_sha:, head_sha:, people_source:, evaluated_at:, base_tree: nil, head_tree: nil, markdown_limit: DEFAULT_MARKDOWN_LIMIT) + affected_groups = if base_tree && head_tree + Entitlements::SmartDiff::Scope.affected_groups( + base_config: base_config, + head_config: head_config, + base_tree: base_tree, + head_tree: head_tree + ) + end common = {people_source: people_source, evaluated_at: evaluated_at} base = Entitlements::DesiredGroups.export( config_file: base_config, @@ -27,16 +36,20 @@ def self.run(base_config:, head_config:, base_sha:, head_sha:, people_source:, e allow_incomplete: true, **common ) - compare(base: base, head: head, markdown_limit: markdown_limit) + compare(base: base, head: head, markdown_limit: markdown_limit, affected_groups: affected_groups) end - def self.compare(base:, head:, markdown_limit: DEFAULT_MARKDOWN_LIMIT) + def self.compare(base:, head:, markdown_limit: DEFAULT_MARKDOWN_LIMIT, affected_groups: nil) validate_snapshot!(base, "base") validate_snapshot!(head, "head") raise ArgumentError, "Base and head used different people snapshots" unless base["people_snapshot_sha256"] == head["people_snapshot_sha256"] raise ArgumentError, "Base and head used different evaluation timestamps" unless base["evaluated_at"] == head["evaluated_at"] raise ArgumentError, "markdown_limit must be a positive integer" unless markdown_limit.is_a?(Integer) && markdown_limit.positive? + if affected_groups + base = scoped_snapshot(base, affected_groups) + head = scoped_snapshot(head, affected_groups) + end base_memberships = indexed_memberships(base) head_memberships = indexed_memberships(head) incomplete_groups = Set.new((snapshot_warnings(base) + snapshot_warnings(head)).map { |warning| warning.fetch("entitlement_group") }) @@ -55,6 +68,7 @@ def self.compare(base:, head:, markdown_limit: DEFAULT_MARKDOWN_LIMIT) "gains" => gains, "losses" => losses } + result["scope"] = {"affected_groups" => affected_groups} if affected_groups [result, markdown(result, limit: markdown_limit)] end @@ -83,6 +97,9 @@ def self.markdown(result, limit: DEFAULT_MARKDOWN_LIMIT) "Head: `#{escape_inline(result.fetch('head').fetch('source_sha'))}`", "" ]) + if result["scope"] + lines.concat(["Affected entitlement groups: #{result.fetch('scope').fetch('affected_groups').length}", ""]) + end remaining = limit [["Added", "gains"], ["Removed", "losses"]].each do |heading, key| @@ -153,6 +170,19 @@ def self.snapshot_metadata(snapshot) end private_class_method :snapshot_metadata + def self.scoped_snapshot(snapshot, affected_groups) + included = affected_groups.to_set + warnings = snapshot_warnings(snapshot).select { |warning| included.include?(warning.fetch("entitlement_group")) } + snapshot.merge( + "complete" => warnings.empty?, + "warnings" => warnings, + "memberships" => snapshot.fetch("memberships").select do |record| + included.include?(record.fetch("entitlement_group")) + end + ) + end + private_class_method :scoped_snapshot + def self.escape_table(value) escaped = value.to_s.gsub(/[\r\n]+/, " ").gsub("\\") { "\\\\" } CGI.escapeHTML(escaped).gsub("|") { "\\|" } diff --git a/lib/entitlements/smart_diff/scope.rb b/lib/entitlements/smart_diff/scope.rb new file mode 100644 index 0000000..2c4c498 --- /dev/null +++ b/lib/entitlements/smart_diff/scope.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "digest" +require "set" + +module Entitlements + class SmartDiff + class Scope + GROUP_REFERENCE = /^\s*(?:-\s*)?(?:group|entitlements_group)\s*(?:!=|=|:)\s*["']?([^"'\s#]+)/ + GROUP_FILE_EXTENSIONS = %w[.rb .txt .yaml].freeze + + def self.affected_groups(base_config:, head_config:, base_tree:, head_tree:) + base = catalog(config_file: base_config, tree: base_tree) + head = catalog(config_file: head_config, tree: head_tree) + all_groups = base.fetch(:groups) | head.fetch(:groups) + changed_groups = changed_groups(base, head) + reverse_dependencies = reverse_dependencies(base, head, all_groups) + + affected = changed_groups.dup + pending = changed_groups.to_a + until pending.empty? + group = pending.shift + reverse_dependencies.fetch(group, Set.new).each do |dependent| + next if affected.include?(dependent) + + affected.add(dependent) + pending << dependent + end + end + affected.to_a.sort + end + + def self.catalog(config_file:, tree:) + original_dir = ENV["DIR"] + ENV["DIR"] = File.expand_path(tree) + Entitlements.reset! + Entitlements.config_file = config_file + groups_config = Entitlements.config.fetch("groups") + groups = Set.new + 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 unless GROUP_FILE_EXTENSIONS.include?(File.extname(filename)) + + group_id = "#{group_name}/#{File.basename(filename, File.extname(filename))}" + relative_path = relative_path(filename, tree) + groups.add(group_id) + path_groups[relative_path].add(group_id) + files[relative_path] = Digest::SHA256.file(filename).hexdigest + next if File.extname(filename) == ".rb" + + File.foreach(filename) do |line| + match = GROUP_REFERENCE.match(line) + references[group_id].add(match[1]) if match + end + 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, + groups: groups, + path_groups: path_groups, + references: references + } + ensure + Entitlements.reset! + original_dir ? ENV["DIR"] = original_dir : ENV.delete("DIR") + end + private_class_method :catalog + + 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.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/spec/unit/entitlements/smart_diff/scope_spec.rb b/spec/unit/entitlements/smart_diff/scope_spec.rb new file mode 100644 index 0000000..ef9883d --- /dev/null +++ b/spec/unit/entitlements/smart_diff/scope_spec.rb @@ -0,0 +1,49 @@ +# 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") + )).to eq([]) + end + + it "includes changed groups, static dependents, and mirrors" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |head| + copy_fixture(base) + copy_fixture(head) + File.open(File.join(head, "groups", "internal", "engineers.txt"), "a") do |file| + file.puts "username = contractor" + end + [base, head].each do |tree| + File.write( + File.join(tree, "groups", "teams", "wildcard.txt"), + "group = internal/*\n" + ) + 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 + )).to eq([ + "internal/engineers", + "teams/nested", + "teams/wildcard", + "teams_mirror/nested", + "teams_mirror/wildcard" + ]) + end + end + end +end diff --git a/spec/unit/entitlements/smart_diff_spec.rb b/spec/unit/entitlements/smart_diff_spec.rb index 24c20b2..6f9588d 100644 --- a/spec/unit/entitlements/smart_diff_spec.rb +++ b/spec/unit/entitlements/smart_diff_spec.rb @@ -106,8 +106,20 @@ head_tree: fixture("smart-diff") ) expect(result["counts"]).to eq("gains" => 0, "losses" => 0) + expect(result["complete"]).to be true + expect(result["scope"]).to eq("affected_groups" => []) expect(result["base"]["people_snapshot_sha256"]).to eq(result["head"]["people_snapshot_sha256"]) expect(result["base"]["evaluated_at"]).to eq(result["head"]["evaluated_at"]) + + unscoped, = described_class.run( + base_config: common[:config_file], + head_config: common[:config_file], + base_sha: "a" * 40, + head_sha: "b" * 40, + people_source: common[:people_source], + evaluated_at: common[:evaluated_at] + ) + expect(unscoped).not_to have_key("scope") end it "rejects incomplete or inconsistent snapshots" do diff --git a/spec/unit/fixtures/smart-diff/config.yaml b/spec/unit/fixtures/smart-diff/config.yaml index 628e40d..706124d 100644 --- a/spec/unit/fixtures/smart-diff/config.yaml +++ b/spec/unit/fixtures/smart-diff/config.yaml @@ -14,6 +14,9 @@ filters: groups: internal: type: dummy + missing: + type: dummy + dir: missing teams: type: dummy base: ou=Teams,dc=example,dc=com