Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
6 changes: 6 additions & 0 deletions bin/entitlements-smart-diff
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
4 changes: 2 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 Down
35 changes: 35 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 @@ -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
Comment on lines +107 to +109
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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
56 changes: 42 additions & 14 deletions lib/entitlements/data/groups/calculated.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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']}"

Expand All @@ -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
Expand Down
13 changes: 10 additions & 3 deletions lib/entitlements/data/groups/calculated/base.rb
Original file line number Diff line number Diff line change
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
end
message = "Invalid expiration date #{expiration.inspect} in #{context} (expected format: YYYY-MM-DD)"
raise ArgumentError, message
Expand All @@ -243,7 +243,14 @@ 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}")
Entitlements.cache.fetch(:file_objects, {}).delete(filename)
raise
end

# This should be the last item on the dependencies array, so pop it off.
unless Entitlements.cache[:dependencies].last == "#{rou}/#{cn}"
Expand Down Expand Up @@ -340,7 +347,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)
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
33 changes: 32 additions & 1 deletion lib/entitlements/data/groups/calculated/ruby.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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
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
result
end

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