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
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
68 changes: 68 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,77 @@ def self.reset!
@config = nil
@config_file = nil
@config_path_override = nil
@evaluation_time = nil
@person_extra_methods = {}

reset_extras!
reset_rule_classes!
Entitlements::Data::Groups::Calculated.reset!
end

# Remove classes loaded from Ruby entitlement files so separate evaluations cannot
# retain class-level descriptions, filters, metadata, or methods.
#
# Takes no arguments.
def self.reset_rule_classes!
Array(@loaded_rule_constant_paths).sort_by { |path| -path.count(":") }.each do |path|
parent_name, _, constant_name = path.rpartition("::")
parent = Kernel.const_get(parent_name)
constant = constant_name.to_sym
parent.send(:remove_const, constant) if parent.const_defined?(constant, false)
end
@loaded_rule_constant_paths = nil
end

# Return all constants currently defined below Entitlements::Rule.
#
# Takes no arguments.
def self.rule_constant_paths
return Set.new unless const_defined?(:Rule, false)

collect_rule_constant_paths(Entitlements::Rule, "Entitlements::Rule", Set.new, Set.new)
end

# Record constants introduced by loading an entitlement Ruby file.
#
# paths - Set of fully qualified constant names.
def self.record_rule_constants(paths)
@loaded_rule_constant_paths ||= Set.new
@loaded_rule_constant_paths.merge(paths)
end

def self.collect_rule_constant_paths(namespace, prefix, result, visited)
return result if visited.include?(namespace.object_id)

visited.add(namespace.object_id)
namespace.constants(false).each do |constant|
path = "#{prefix}::#{constant}"
result.add(path)
value = namespace.const_get(constant, false)
collect_rule_constant_paths(value, path, result, visited) if value.is_a?(Module)
end
result
end
private_class_method :collect_rule_constant_paths

# Return the time used for date-sensitive entitlement evaluation.
#
# Returns a Time.
Contract C::None => Time
def self.evaluation_time
@evaluation_time || Time.now
end

# Set the time used for date-sensitive entitlement evaluation.
#
# value - A Time.
#
# Returns the supplied Time.
Contract Time => Time
def self.evaluation_time=(value)
@evaluation_time = value
end

def self.reset_extras!
extras_loaded = @extras_loaded
if extras_loaded
Expand Down Expand Up @@ -600,6 +666,7 @@ def self.cache
require_relative "entitlements/cli"
require_relative "entitlements/data/groups"
require_relative "entitlements/data/people"
require_relative "entitlements/desired_groups"
require_relative "entitlements/extras"
require_relative "entitlements/extras/base"
require_relative "entitlements/models/action"
Expand All @@ -611,6 +678,7 @@ def self.cache
require_relative "entitlements/plugins/posix_group"
require_relative "entitlements/rule/base"
require_relative "entitlements/service/ldap"
require_relative "entitlements/smart_diff"
require_relative "entitlements/util/mirror"
require_relative "entitlements/util/override"
require_relative "entitlements/util/util"
43 changes: 29 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,24 @@ def self.read_all(ou_key, cfg_obj, skip_broken_references: false)
group_dn = ["cn=#{file_without_extension}", cfg_obj.fetch("base")].join(",")

# Use the ruleset to build the group.
options = { skip_broken_references: skip_broken_references }

Entitlements.cache[:file_objects][filename] ||= ruleset(filename: filename, config: cfg_obj, options: options)
@groups_cache[group_dn] = Entitlements::Models::Group.new(
dn: group_dn,
members: Entitlements.cache[:file_objects][filename].modified_filtered_members,
description: Entitlements.cache[:file_objects][filename].description,
metadata: Entitlements.cache[:file_objects][filename].metadata.merge("_filename" => filename)
)
options = {
skip_broken_references: skip_broken_references,
skip_dynamic_groups: skip_dynamic_groups
}

begin
Entitlements.cache[:file_objects][filename] ||= ruleset(filename: filename, config: cfg_obj, options: options)
@groups_cache[group_dn] = Entitlements::Models::Group.new(
dn: group_dn,
members: Entitlements.cache[:file_objects][filename].modified_filtered_members,
description: Entitlements.cache[:file_objects][filename].description,
metadata: Entitlements.cache[:file_objects][filename].metadata.merge("_filename" => filename)
)
rescue DynamicGroupError
raise unless skip_dynamic_groups

next
end
result.add group_dn
end

Expand Down Expand Up @@ -152,8 +165,10 @@ def self.all_groups
# cfg_obj - Hash with the configuration for that key from the configuration file.
#
# Returns a Set of Strings (DNs) of the groups in this OU.
Contract String, C::HashOf[String => C::Any] => C::SetOf[String]
def self.read_mirror(ou_key, cfg_obj)
Contract String, C::HashOf[String => C::Any], C::KeywordArgs[
skip_dynamic_groups: C::Optional[C::Bool]
] => C::SetOf[String]
def self.read_mirror(ou_key, cfg_obj, skip_dynamic_groups: false)
@groups_in_ou_cache[ou_key] ||= begin
Entitlements.logger.debug "Mirroring #{ou_key} from #{cfg_obj['mirror']}"

Expand Down
17 changes: 13 additions & 4 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
end
message = "Invalid expiration date #{expiration.inspect} in #{context} (expected format: YYYY-MM-DD)"
raise ArgumentError, message
Expand All @@ -243,7 +243,16 @@ def members_from_rules(rule)
Entitlements.cache[:dependencies] << "#{rou}/#{cn}"

# Actually calculate it.
Entitlements.cache[:calculated][rou][cn] = _members_from_rules(rule)
begin
Entitlements.cache[:calculated][rou][cn] = _members_from_rules(rule)
rescue Entitlements::Data::Groups::Calculated::DynamicGroupError
Comment thread
hosom marked this conversation as resolved.
raise unless options[:skip_dynamic_groups]

Entitlements.cache[:calculated][rou].delete(cn)
Entitlements.cache[:dependencies].delete("#{rou}/#{cn}")
Entitlements.cache.fetch(:file_objects, {}).delete(filename)
raise
end

# This should be the last item on the dependencies array, so pop it off.
unless Entitlements.cache[:dependencies].last == "#{rou}/#{cn}"
Expand Down Expand Up @@ -340,7 +349,7 @@ def handle_or(rule)
# Returns C::SetOf[Entitlements::Models::Person] from a recursive call.
def handle_and(rule)
ensure_type!("and", rule, Array)
return result unless rule.any?
return Set.new unless rule.any?

first_rule = rule.shift
ensure_type!("and", first_rule, Hash)
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
12 changes: 11 additions & 1 deletion lib/entitlements/data/groups/calculated/ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,17 @@ def initialize_metadata
Contract C::None => Object
def rule_obj
@rule_obj ||= begin
require filename
if options[:skip_dynamic_groups]
raise Entitlements::Data::Groups::Calculated::DynamicGroupError,
"Dynamic group #{rou}/#{cn} uses arbitrary Ruby code"
end

constants_before_load = Entitlements.rule_constant_paths
begin
load filename
ensure
Entitlements.record_rule_constants(Entitlements.rule_constant_paths - constants_before_load)
end
clazz = Kernel.const_get(ruby_class_name)
clazz.new
end
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.

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