Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3714891
Add CI-ready entitlement smart diff
hosom Sep 2, 2026
067c29d
Handle empty calculated AND rules
hosom Sep 2, 2026
f4bceb0
Clear dynamic group file state
hosom Sep 2, 2026
b604b47
Do not cache in-progress group results
hosom Sep 2, 2026
a89b2d7
Escape backslashes in smart diff Markdown
hosom Sep 2, 2026
39dee2d
Scope smart diff to affected groups
hosom Sep 2, 2026
abdb41c
Harden affected-group dependency discovery
hosom Sep 3, 2026
540a75a
Address smart diff review feedback
hosom Sep 3, 2026
f843155
Skip dynamic groups in smart diff
hosom Sep 3, 2026
97222bf
Evaluate affected dynamic groups in smart diff
hosom Sep 15, 2026
fcb3f5f
Freeze evaluation time for each run
hosom Sep 16, 2026
2c7cccb
Isolate smart diff snapshots by process
hosom Sep 16, 2026
23d7128
Calculate smart diff snapshots concurrently
hosom Sep 16, 2026
f2e5507
Load required features in snapshot workers
hosom Sep 16, 2026
c07c790
Fail closed on smart diff identity changes
hosom Sep 18, 2026
552fac0
Export smart diff as SQLite
hosom Sep 18, 2026
0375819
Vendor precompiled Linux SQLite gem
hosom Sep 18, 2026
4f855bf
Lock SQLite for Linux runners
hosom Sep 18, 2026
cf72709
Use supported SQLite runtime
hosom Sep 18, 2026
ee7ad61
Support normalized Linux gem platform
hosom Sep 18, 2026
3f50b77
Use bundled Extralite for SQLite
hosom Sep 18, 2026
d0fb95c
Keep smart diff SQLite facts-only
hosom Sep 18, 2026
5e8c328
Normalize smart diff identity facts
hosom Sep 18, 2026
567cf48
Rely on process isolation for Ruby rules
hosom Sep 21, 2026
78f8504
Skip ignored files in smart diff scope catalog
hosom Sep 21, 2026
8102f0e
Decouple smart diff identity snapshots
hosom Sep 22, 2026
09c2e8f
Document smart diff concepts
hosom Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions bin/entitlements-smart-diff

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to know your thinking around shipping this in this repo instead of in github/entitlements.

We don't really have any concept of 'CI' or 'diff' in here. But we have been using noop deploys to compute the effective diff, which gives us the "real diff" because it also talks to all the downstream systems.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ambivalent to which repo it ships in. We could make the 2-3 changes that are needed to the entitlements core (which are also bugfixes), and then move the bulk of this code to our private configuration repo and I'd be OK with that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer that yeah

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env ruby

require "entitlements"
require "entitlements/smart_diff/cli"

exit Entitlements::SmartDiff::Cli.run
5 changes: 3 additions & 2 deletions entitlements-app.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand Down
39 changes: 39 additions & 0 deletions lib/entitlements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down Expand Up @@ -90,13 +91,49 @@ def self.reset!
@config = nil
@config_file = nil
@config_path_override = nil
@evaluation_time = nil
@person_extra_methods = {}
@statsd = nil

reset_extras!
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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
1 change: 1 addition & 0 deletions lib/entitlements/data/groups/calculated.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions lib/entitlements/data/groups/calculated/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can hit bugs by repeatedly calling Time.now throughout long running entitlements calculations, so I am encouraging the use of a time snapshot at the start of the run, rather than evaluating Time.now repeatedly during execution.

end
message = "Invalid expiration date #{expiration.inspect} in #{context} (expected format: YYYY-MM-DD)"
raise ArgumentError, message
Expand Down Expand Up @@ -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?

@hosom hosom Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

result was previously an uninitialized value. This is a bugfix with an accompanying test.


first_rule = rule.shift
ensure_type!("and", first_rule, Hash)
Expand Down
9 changes: 6 additions & 3 deletions lib/entitlements/data/groups/calculated/filters/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/entitlements/data/groups/calculated/rules/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions lib/entitlements/data/groups/calculated/text.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is both a general evaluator bug fix and a requirement for smart diff: :calculating is a transient in-progress sentinel, so caching it here can permanently poison the file object and prevent a later retry from producing the actual member set.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, I was about to ask about the logic update. Make sense

result
end

# Standard interface: Get the description of this group.
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions lib/entitlements/data/groups/calculated/yaml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is both a general evaluator bug fix and a requirement for smart diff: :calculating is a transient in-progress sentinel, so caching it here can permanently poison the file object and prevent a later retry from producing the actual member set.

result
end

# Standard interface: Get the description of this group.
Expand Down
Loading
Loading