entitlements 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/VERSION +1 -0
  3. data/bin/deploy-entitlements +18 -0
  4. data/lib/entitlements/auditor/base.rb +163 -0
  5. data/lib/entitlements/backend/base_controller.rb +171 -0
  6. data/lib/entitlements/backend/base_provider.rb +55 -0
  7. data/lib/entitlements/backend/dummy/controller.rb +89 -0
  8. data/lib/entitlements/backend/dummy.rb +3 -0
  9. data/lib/entitlements/backend/ldap/controller.rb +188 -0
  10. data/lib/entitlements/backend/ldap/provider.rb +128 -0
  11. data/lib/entitlements/backend/ldap.rb +4 -0
  12. data/lib/entitlements/backend/member_of/controller.rb +203 -0
  13. data/lib/entitlements/backend/member_of.rb +3 -0
  14. data/lib/entitlements/cli.rb +121 -0
  15. data/lib/entitlements/data/groups/cached.rb +120 -0
  16. data/lib/entitlements/data/groups/calculated/base.rb +478 -0
  17. data/lib/entitlements/data/groups/calculated/filters/base.rb +93 -0
  18. data/lib/entitlements/data/groups/calculated/filters/member_of_group.rb +32 -0
  19. data/lib/entitlements/data/groups/calculated/modifiers/base.rb +38 -0
  20. data/lib/entitlements/data/groups/calculated/modifiers/expiration.rb +56 -0
  21. data/lib/entitlements/data/groups/calculated/ruby.rb +137 -0
  22. data/lib/entitlements/data/groups/calculated/rules/base.rb +35 -0
  23. data/lib/entitlements/data/groups/calculated/rules/group.rb +129 -0
  24. data/lib/entitlements/data/groups/calculated/rules/username.rb +41 -0
  25. data/lib/entitlements/data/groups/calculated/text.rb +337 -0
  26. data/lib/entitlements/data/groups/calculated/yaml.rb +171 -0
  27. data/lib/entitlements/data/groups/calculated.rb +290 -0
  28. data/lib/entitlements/data/groups.rb +13 -0
  29. data/lib/entitlements/data/people/combined.rb +197 -0
  30. data/lib/entitlements/data/people/dummy.rb +71 -0
  31. data/lib/entitlements/data/people/ldap.rb +142 -0
  32. data/lib/entitlements/data/people/yaml.rb +102 -0
  33. data/lib/entitlements/data/people.rb +58 -0
  34. data/lib/entitlements/extras/base.rb +40 -0
  35. data/lib/entitlements/extras/ldap_group/base.rb +20 -0
  36. data/lib/entitlements/extras/ldap_group/filters/member_of_ldap_group.rb +50 -0
  37. data/lib/entitlements/extras/ldap_group/rules/ldap_group.rb +69 -0
  38. data/lib/entitlements/extras/orgchart/base.rb +32 -0
  39. data/lib/entitlements/extras/orgchart/logic.rb +171 -0
  40. data/lib/entitlements/extras/orgchart/person_methods.rb +55 -0
  41. data/lib/entitlements/extras/orgchart/rules/direct_report.rb +62 -0
  42. data/lib/entitlements/extras/orgchart/rules/management.rb +59 -0
  43. data/lib/entitlements/extras.rb +82 -0
  44. data/lib/entitlements/models/action.rb +82 -0
  45. data/lib/entitlements/models/group.rb +280 -0
  46. data/lib/entitlements/models/person.rb +149 -0
  47. data/lib/entitlements/plugins/dummy.rb +22 -0
  48. data/lib/entitlements/plugins/group_of_names.rb +28 -0
  49. data/lib/entitlements/plugins/posix_group.rb +46 -0
  50. data/lib/entitlements/plugins.rb +13 -0
  51. data/lib/entitlements/rule/base.rb +74 -0
  52. data/lib/entitlements/service/ldap.rb +405 -0
  53. data/lib/entitlements/util/mirror.rb +42 -0
  54. data/lib/entitlements/util/override.rb +64 -0
  55. data/lib/entitlements/util/util.rb +219 -0
  56. data/lib/entitlements.rb +606 -0
  57. metadata +343 -0
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/ldap"
4
+
5
+ module Entitlements
6
+ class Data
7
+ class People
8
+ class LDAP
9
+ include ::Contracts::Core
10
+ C = ::Contracts
11
+
12
+ # Default attributes
13
+ PEOPLE_ATTRIBUTES = %w[cn]
14
+ UID_ATTRIBUTE = "uid"
15
+
16
+ # Parameters
17
+ PARAMETERS = {
18
+ "base" => { required: true, type: String },
19
+ "ldap_binddn" => { required: true, type: String },
20
+ "ldap_bindpw" => { required: true, type: String },
21
+ "ldap_uri" => { required: true, type: String },
22
+ "ldap_ca_file" => { required: false, type: String },
23
+ "person_dn_format" => { required: true, type: String },
24
+ "disable_ssl_verification" => { required: false, type: [FalseClass, TrueClass] },
25
+ "additional_attributes" => { required: false, type: Array },
26
+ "uid_attribute" => { required: false, type: String }
27
+ }
28
+
29
+ # Fingerprint for the object based on unique parameters from the group configuration. If the fingerprint
30
+ # matches the same object should be re-used. This will raise an error if insufficient configuration is
31
+ # given.
32
+ #
33
+ # config - Hash of configuration values as may be found in the Entitlements configuration file.
34
+ #
35
+ # Returns a String with the "fingerprint" for this configuration.
36
+ Contract C::HashOf[String => C::Any] => String
37
+ def self.fingerprint(config)
38
+ PARAMETERS.keys.map { |key| config[key].inspect }.join("||")
39
+ end
40
+
41
+ # Construct this object based on parameters in a group configuration. This is the direct translation
42
+ # between the Entitlements configuration file (which is always a Hash with configuration values) and
43
+ # the object constructed from this class (which can have whatever structure makes sense).
44
+ #
45
+ # config - Hash of configuration values as may be found in the Entitlements configuration file.
46
+ #
47
+ # Returns Entitlements::Data::People::LDAP object.
48
+ # :nocov:
49
+ Contract C::HashOf[String => C::Any] => Entitlements::Data::People::LDAP
50
+ def self.new_from_config(config)
51
+ new(
52
+ ldap: Entitlements::Service::LDAP.new_with_cache(
53
+ addr: config.fetch("ldap_uri"),
54
+ binddn: config.fetch("ldap_binddn"),
55
+ bindpw: config.fetch("ldap_bindpw"),
56
+ ca_file: config.fetch("ldap_ca_file", ENV["LDAP_CACERT"]),
57
+ disable_ssl_verification: config.fetch("ldap_disable_ssl_verification", false),
58
+ person_dn_format: config.fetch("person_dn_format")
59
+ ),
60
+ people_ou: config.fetch("base"),
61
+ uid_attr: config.fetch("uid_attribute", UID_ATTRIBUTE),
62
+ people_attr: config.fetch("additional_attributes", PEOPLE_ATTRIBUTES)
63
+ )
64
+ end
65
+ # :nocov:
66
+
67
+ # Validate configuration options.
68
+ #
69
+ # key - String with the name of the data source.
70
+ # config - Hash with the configuration data.
71
+ #
72
+ # Returns nothing.
73
+ # :nocov:
74
+ Contract String, C::HashOf[String => C::Any] => nil
75
+ def self.validate_config!(key, config)
76
+ text = "LDAP people configuration for data source #{key.inspect}"
77
+ Entitlements::Util::Util.validate_attr!(PARAMETERS, config, text)
78
+ end
79
+ # :nocov:
80
+
81
+ # Constructor.
82
+ #
83
+ # ldap - Entitlements::Service::LDAP object
84
+ # people_ou - String containing the OU in which people reside
85
+ # dn_format - How to translate a UID to a DN (e.g. uid=%KEY%,ou=People,dc=kittens,dc=net)
86
+ # uid_attr - Optional String with attribute name for the user ID (default: uid)
87
+ # people_attr - Optional Array of Strings attributes of people that should be fetched from LDAP
88
+ Contract C::KeywordArgs[
89
+ ldap: Entitlements::Service::LDAP,
90
+ people_ou: String,
91
+ uid_attr: C::Maybe[String],
92
+ people_attr: C::Maybe[C::ArrayOf[String]]
93
+ ] => C::Any
94
+ def initialize(ldap:, people_ou:, uid_attr: UID_ATTRIBUTE, people_attr: PEOPLE_ATTRIBUTES)
95
+ @ldap = ldap
96
+ @people_ou = people_ou
97
+ @uid_attr = uid_attr
98
+ @people_attr = people_attr
99
+ end
100
+
101
+ # Read in the people from LDAP. Cache result for later access.
102
+ #
103
+ # uid - Optionally a uid to return. If not specified, returns the entire hash.
104
+ #
105
+ # Returns Hash of { uid => Entitlements::Models::Person } or one Entitlements::Models::Person.
106
+ Contract C::Maybe[String] => C::Or[C::HashOf[String => Entitlements::Models::Person], Entitlements::Models::Person]
107
+ def read(uid = nil)
108
+ @people ||= begin
109
+ Entitlements.logger.debug "Loading people from LDAP"
110
+ ldap.search(base: people_ou, filter: Net::LDAP::Filter.eq(uid_attr, "*"), attrs: people_attr.sort)
111
+ .map { |person_dn, entry| [Entitlements::Util::Util.first_attr(person_dn).downcase, entry_to_person(entry)] }
112
+ .to_h
113
+ end
114
+
115
+ return @people if uid.nil?
116
+ return @people[uid.downcase] if @people[uid.downcase]
117
+ raise Entitlements::Data::People::NoSuchPersonError, "read(#{uid.inspect}) matched no known person"
118
+ end
119
+
120
+ private
121
+
122
+ attr_reader :ldap, :people_ou, :uid_attr, :people_attr
123
+
124
+ # Construct an Entitlements::Models::Person from a Net::LDAP::Entry
125
+ #
126
+ # entry - The Net::LDAP::Entry
127
+ #
128
+ # Returns an Entitlements::Models::Person object.
129
+ Contract Net::LDAP::Entry => Entitlements::Models::Person
130
+ def entry_to_person(entry)
131
+ attributes = people_attr
132
+ .map { |k| [k.to_s, entry[k.to_sym]] }
133
+ .to_h
134
+ Entitlements::Models::Person.new(
135
+ uid: Entitlements::Util::Util.first_attr(entry.dn),
136
+ attributes: attributes
137
+ )
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Entitlements
6
+ class Data
7
+ class People
8
+ class YAML
9
+ include ::Contracts::Core
10
+ C = ::Contracts
11
+
12
+ # Parameters
13
+ PARAMETERS = {
14
+ "filename" => { required: true, type: String }
15
+ }
16
+
17
+ # Fingerprint for the object based on unique parameters from the group configuration. If the fingerprint
18
+ # matches the same object should be re-used. This will raise an error if insufficient configuration is
19
+ # given.
20
+ #
21
+ # config - Hash of configuration values as may be found in the Entitlements configuration file.
22
+ #
23
+ # Returns a String with the "fingerprint" for this configuration.
24
+ Contract C::HashOf[String => C::Any] => String
25
+ def self.fingerprint(config)
26
+ PARAMETERS.keys.map { |key| config[key].inspect }.join("||")
27
+ end
28
+
29
+ # Construct this object based on parameters in a group configuration. This is the direct translation
30
+ # between the Entitlements configuration file (which is always a Hash with configuration values) and
31
+ # the object constructed from this class (which can have whatever structure makes sense).
32
+ #
33
+ # config - Hash of configuration values as may be found in the Entitlements configuration file.
34
+ #
35
+ # Returns Entitlements::Data::People::YAML object.
36
+ # :nocov:
37
+ Contract C::HashOf[String => C::Any] => Entitlements::Data::People::YAML
38
+ def self.new_from_config(config)
39
+ new(filename: config.fetch("filename"))
40
+ end
41
+ # :nocov:
42
+
43
+ # Validate configuration options.
44
+ #
45
+ # key - String with the name of the data source.
46
+ # config - Hash with the configuration data.
47
+ #
48
+ # Returns nothing.
49
+ # :nocov:
50
+ Contract String, C::HashOf[String => C::Any] => nil
51
+ def self.validate_config!(key, config)
52
+ text = "YAML people configuration for data source #{key.inspect}"
53
+ Entitlements::Util::Util.validate_attr!(PARAMETERS, config, text)
54
+ end
55
+ # :nocov:
56
+
57
+ # Constructor.
58
+ #
59
+ # filename - String with the filename to read.
60
+ # people - Optionally, Hash of { uid => Entitlements::Models::Person }
61
+ Contract C::KeywordArgs[
62
+ filename: String,
63
+ people: C::Maybe[C::HashOf[String => Entitlements::Models::Person]]
64
+ ] => C::Any
65
+ def initialize(filename:, people: nil)
66
+ @filename = filename
67
+ @people = people
68
+ @people_downcase = nil
69
+ end
70
+
71
+ # Read in the people from a file. Cache result for later access.
72
+ #
73
+ # uid - Optionally a uid to return. If not specified, returns the entire hash.
74
+ #
75
+ # Returns Hash of { uid => Entitlements::Models::Person } or one Entitlements::Models::Person.
76
+ Contract C::Maybe[String] => C::Or[Entitlements::Models::Person, C::HashOf[String => Entitlements::Models::Person]]
77
+ def read(uid = nil)
78
+ @people ||= begin
79
+ Entitlements.logger.debug "Loading people from #{filename.inspect}"
80
+ raw_person_data = ::YAML.load(File.read(filename)).to_h
81
+ raw_person_data.map do |id, data|
82
+ [id, Entitlements::Models::Person.new(uid: id, attributes: data)]
83
+ end.to_h
84
+ end
85
+ return @people if uid.nil?
86
+
87
+ # Requested a specific user ID
88
+ @people_downcase ||= @people.map { |person_uid, data| [person_uid.downcase, person_uid] }.to_h
89
+ unless @people_downcase.key?(uid.downcase)
90
+ raise Entitlements::Data::People::NoSuchPersonError, "read(#{uid.inspect}) matched no known person"
91
+ end
92
+
93
+ @people[@people_downcase[uid.downcase]]
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :filename
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "people/combined"
4
+ require_relative "people/dummy"
5
+ require_relative "people/ldap"
6
+ require_relative "people/yaml"
7
+
8
+ module Entitlements
9
+ class Data
10
+ class People
11
+ class NoSuchPersonError < RuntimeError; end
12
+
13
+ include ::Contracts::Core
14
+ C = ::Contracts
15
+
16
+ PEOPLE_CLASSES = {
17
+ "combined" => Entitlements::Data::People::Combined,
18
+ "dummy" => Entitlements::Data::People::Dummy,
19
+ "ldap" => Entitlements::Data::People::LDAP,
20
+ "yaml" => Entitlements::Data::People::YAML
21
+ }
22
+
23
+ # Gets the class for the specified configuration. Basically this is a wrapper around PEOPLE_CLASSES
24
+ # with friendly error messages if a configuration is insufficient to select the class.
25
+ #
26
+ # config - Hash of configuration values as may be found in the Entitlements configuration file.
27
+ #
28
+ # Returns Entitlements::Data::People class.
29
+ Contract C::HashOf[String => C::Any] => Class
30
+ def self.class_for_config(config)
31
+ unless config.key?("type")
32
+ raise ArgumentError, "'type' is undefined in: #{config.inspect}"
33
+ end
34
+
35
+ unless Entitlements::Data::People::PEOPLE_CLASSES.key?(config["type"])
36
+ raise ArgumentError, "'type' #{config['type'].inspect} is invalid!"
37
+ end
38
+
39
+ Entitlements::Data::People::PEOPLE_CLASSES.fetch(config["type"])
40
+ end
41
+
42
+ # Constructor to build an object from the configuration file. Given a key in the `groups`
43
+ # section, check the `people` key for one or more data sources for people records. Construct
44
+ # the underlying object(s) as necessary while caching duplicate objects.
45
+ #
46
+ # config - Hash of configuration values as may be found in the Entitlements configuration file.
47
+ #
48
+ # Returns Entitlements::Data::People object backed by appropriate object(s).
49
+ Contract C::HashOf[String => C::Any] => C::Any
50
+ def self.new_from_config(config)
51
+ Entitlements.cache[:people_class] ||= {}
52
+ clazz = class_for_config(config)
53
+ fingerprint = clazz.fingerprint(config.fetch("config"))
54
+ Entitlements.cache[:people_class][fingerprint] ||= clazz.new_from_config(config.fetch("config"))
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inherited methods for extras in this directory (or in other directories).
4
+
5
+ module Entitlements
6
+ module Extras
7
+ class Base
8
+ include ::Contracts::Core
9
+ C = ::Contracts
10
+
11
+ # Retrieve the configuration for this extra from the Entitlements configuration
12
+ # file. Returns the hash of configuration if found, or an empty hash in all other
13
+ # cases.
14
+ #
15
+ # Takes no arguments.
16
+ #
17
+ # Returns a Hash.
18
+ Contract C::None => C::HashOf[String => C::Any]
19
+ def self.config
20
+ @extra_config ||= begin
21
+ # classname is something like "Entitlements::Extras::MyExtraClassName::Base" - want to pull
22
+ # out the "MyExtraClassName" from this string.
23
+ classname = self.to_s.split("::")[-2]
24
+ decamelized_class = Entitlements::Util::Util.decamelize(classname)
25
+ cfg = Entitlements.config.fetch("extras", {}).fetch(decamelized_class, nil)
26
+ cfg.is_a?(Hash) ? cfg : {}
27
+ end
28
+ end
29
+
30
+ # This is intended for unit tests to reset class variables.
31
+ #
32
+ # Takes no arguments.
33
+ #
34
+ # Returns nothing.
35
+ def self.reset!
36
+ @extra_config = nil
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+
5
+ module Entitlements
6
+ module Extras
7
+ class LDAPGroup
8
+ class Base < Entitlements::Extras::Base
9
+ def self.init
10
+ require_relative "filters/member_of_ldap_group"
11
+ require_relative "rules/ldap_group"
12
+ end
13
+
14
+ def self.rules
15
+ %w[ldap_group]
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Filter class to remove members of a particular LDAP group.
4
+
5
+ module Entitlements
6
+ module Extras
7
+ class LDAPGroup
8
+ class Filters
9
+ class MemberOfLDAPGroup < Entitlements::Data::Groups::Calculated::Filters::Base
10
+ include ::Contracts::Core
11
+ C = ::Contracts
12
+
13
+ # Determine if the member is filtered as per this definition. Return true if the member
14
+ # is to be filtered out, false if the member does not match the filter.
15
+ #
16
+ # member - Entitlements::Models::Person object
17
+ #
18
+ # Returns true if the person is to be filtered out, false otherwise.
19
+ Contract Entitlements::Models::Person => C::Bool
20
+ def filtered?(member)
21
+ return false if filter == :all
22
+ return false unless member_of_ldap_group?(member, config.fetch("ldap_group"))
23
+ return true if filter == :none
24
+ !member_of_filter?(member)
25
+ end
26
+
27
+ # Helper method: Determine if the person is a member of an LDAP group that exists in
28
+ # the directory but is not managed by entitlements.
29
+ #
30
+ # member - Entitlements::Models::Person object
31
+ # group_dn - LDAP distinguished name of the group
32
+ #
33
+ # Returns true if a member of the group, false otherwise.
34
+ Contract Entitlements::Models::Person, String => C::Bool
35
+ def member_of_ldap_group?(member, group_dn)
36
+ Entitlements.cache[:member_of_ldap_group] ||= {}
37
+ Entitlements.cache[:member_of_ldap_group][group_dn] ||= begin
38
+ member_set = Entitlements::Extras::LDAPGroup::Rules::LDAPGroup.matches(value: group_dn)
39
+ member_set.map { |person| person.uid.downcase }
40
+ rescue Entitlements::Data::Groups::GroupNotFoundError
41
+ []
42
+ end
43
+
44
+ Entitlements.cache[:member_of_ldap_group][group_dn].include?(member.uid.downcase)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+ # Is someone in an LDAP group?
3
+
4
+ module Entitlements
5
+ module Extras
6
+ class LDAPGroup
7
+ class Rules
8
+ class LDAPGroup < Entitlements::Data::Groups::Calculated::Rules::Base
9
+ include ::Contracts::Core
10
+ C = ::Contracts
11
+
12
+ # Interface method: Get a Set[Entitlements::Models::Person] matching this condition.
13
+ #
14
+ # value - The value to match.
15
+ # filename - Name of the file resulting in this rule being called
16
+ # options - Optional hash of additional method-specific options
17
+ #
18
+ # Returns a Set[Entitlements::Models::Person].
19
+ Contract C::KeywordArgs[
20
+ value: String,
21
+ filename: C::Maybe[String],
22
+ options: C::Optional[C::HashOf[Symbol => C::Any]]
23
+ ] => C::SetOf[Entitlements::Models::Person]
24
+ def self.matches(value:, filename: nil, options: {})
25
+ Entitlements.cache[:ldap_cache] ||= {}
26
+ Entitlements.cache[:ldap_cache][value] ||= begin
27
+ entry = ldap.read(value)
28
+ unless entry
29
+ message = if filename
30
+ "Failed to read ldap_group = #{value} (referenced in #{filename})"
31
+ else
32
+ # :nocov:
33
+ "Failed to read ldap_group = #{value}"
34
+ # :nocov:
35
+ end
36
+ raise Entitlements::Data::Groups::GroupNotFoundError, message
37
+ end
38
+ Entitlements::Service::LDAP.entry_to_group(entry)
39
+ end
40
+ Entitlements.cache[:ldap_cache][value].members(people_obj: Entitlements.cache[:people_obj])
41
+ end
42
+
43
+ # Object to communicate with an LDAP backend.
44
+ #
45
+ # Takes no arguments.
46
+ #
47
+ # Returns a Entitlements::Service::LDAP object.
48
+ # :nocov:
49
+ Contract C::None => Entitlements::Service::LDAP
50
+ def self.ldap
51
+ @ldap ||= begin
52
+ config = Entitlements::Extras::LDAPGroup::Base.config
53
+ opts = {
54
+ addr: config.fetch("ldap_uri"),
55
+ binddn: config.fetch("ldap_binddn"),
56
+ bindpw: config.fetch("ldap_bindpw"),
57
+ ca_file: config.fetch("ldap_ca_file", ENV["LDAP_CACERT"]),
58
+ person_dn_format: config.fetch("person_dn_format")
59
+ }
60
+ opts[:disable_ssl_verification] = true if config.fetch("disable_ssl_verification", false)
61
+ Entitlements::Service::LDAP.new_with_cache(opts)
62
+ end
63
+ end
64
+ # :nocov:
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require "yaml"
5
+
6
+ module Entitlements
7
+ module Extras
8
+ class Orgchart
9
+ class Base < Entitlements::Extras::Base
10
+ def self.init
11
+ require_relative "logic"
12
+ require_relative "person_methods"
13
+ require_relative "rules/direct_report"
14
+ require_relative "rules/management"
15
+ end
16
+
17
+ def self.rules
18
+ %w[direct_report management]
19
+ end
20
+
21
+ def self.person_methods
22
+ %w[manager]
23
+ end
24
+
25
+ def self.reset!
26
+ super
27
+ Entitlements::Extras::Orgchart::PersonMethods.reset!
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+ # Helper functions to implement our own business logic.
3
+
4
+ module Entitlements
5
+ module Extras
6
+ class Orgchart
7
+ class Logic
8
+ include ::Contracts::Core
9
+ C = ::Contracts
10
+
11
+ # Constructor for the people logic engine. Pass in the indexed hash of people in the organization
12
+ # and this will handle indexing, caching, and reporting relationship logic.
13
+ #
14
+ # people_hash - A Hash of { dn => Entitlements::Models::Person LDAP Object }
15
+ Contract C::KeywordArgs[
16
+ people: C::HashOf[String => Entitlements::Models::Person],
17
+ ] => C::Any
18
+ def initialize(people:)
19
+ @people_hash = people.map { |uid, person| [uid.downcase, person] }.to_h
20
+ @direct_reports_cache = nil
21
+ @all_reports_cache = nil
22
+ end
23
+
24
+ # Calculate the direct reports of a given person, returning a Set of all of the people who report to
25
+ # that person. Does NOT return the manager themselves as part of the result set. Returns an empty set
26
+ # if the person has nobody directly reporting to them.
27
+ #
28
+ # manager - Entitlements::Models::Person who is the manager or higher
29
+ #
30
+ # Returns a Set of Entitlements::Models::Person's.
31
+ Contract Entitlements::Models::Person => C::SetOf[Entitlements::Models::Person]
32
+ def direct_reports(manager)
33
+ manager_uid = manager.uid.downcase
34
+ direct_reports_cache.key?(manager_uid) ? direct_reports_cache[manager_uid] : Set.new
35
+ end
36
+
37
+ # Calculate the all reports of a given person, returning a Set of all of the people who report to
38
+ # that person directly or indirectly. Does NOT return the manager themselves as part of the result
39
+ # set. Returns an empty set if the person has nobody reporting to them.
40
+ #
41
+ # manager - Entitlements::Models::Person who is the manager or higher
42
+ #
43
+ # Returns a Set of LDAP object Entitlements::Models::Persons.
44
+ Contract Entitlements::Models::Person => C::SetOf[Entitlements::Models::Person]
45
+ def all_reports(manager)
46
+ manager_uid = manager.uid.downcase
47
+ all_reports_cache.key?(manager_uid) ? all_reports_cache[manager_uid] : Set.new
48
+ end
49
+
50
+ # Calculate the management chain for the person, returning a Set of all managers in that chain all
51
+ # the way to the top of the tree.
52
+ #
53
+ # person - Entitlements::Models::Person object
54
+ #
55
+ # Returns a Set of LDAP object openstructs.
56
+ Contract Entitlements::Models::Person => C::SetOf[Entitlements::Models::Person]
57
+ def management_chain(person)
58
+ person_uid = person.uid.downcase
59
+
60
+ @management_chain_cache ||= {}
61
+ return @management_chain_cache[person_uid] if @management_chain_cache[person_uid].is_a?(Set)
62
+
63
+ @management_chain_cache[person_uid] = Set.new
64
+ if person.manager && person.manager != person.uid
65
+ person_manager_uid = person.manager.downcase
66
+ unless @people_hash.key?(person_manager_uid)
67
+ # :nocov:
68
+ raise ArgumentError, "Manager #{person.manager.inspect} for person #{person.uid.inspect} does not exist!"
69
+ # :nocov:
70
+ end
71
+ # The recursive logic here will also define the management_chain_cache value for the manager,
72
+ # and when calculating that it will define the management_chain_cache value for that manager's manager,
73
+ # and so on. This ensures that each person's manager is only computed one time (when it's used) and
74
+ # subsequent lookups are all faster.
75
+ @management_chain_cache[person_uid].add @people_hash[person_manager_uid]
76
+ @management_chain_cache[person_uid].merge management_chain(@people_hash[person_manager_uid])
77
+ end
78
+ @management_chain_cache[person_uid]
79
+ end
80
+
81
+ private
82
+
83
+ # Iterate through the entire employee list and build up the lists of direct reports for each
84
+ # manager. Cache this so that the iteration only occurs one time.
85
+ #
86
+ # Returns a Hash of { "dn" => Set(Entitlements::Models::Person) }
87
+ Contract C::None => C::HashOf[String => C::SetOf[Entitlements::Models::Person]]
88
+ def direct_reports_cache
89
+ return @direct_reports_cache if @direct_reports_cache
90
+
91
+ Entitlements.logger.debug "Building #{self.class} direct_reports_cache"
92
+
93
+ @direct_reports_cache = {}
94
+ @people_hash.each do |uid, entry|
95
+ # If this person doesn't have a manager, then bail. (CEO)
96
+ next unless entry.manager && entry.manager != entry.uid
97
+
98
+ # Initialize their manager's list of direct reports if necessary, and add this person
99
+ # to that list.
100
+ person_manager_uid = entry.manager.downcase
101
+ @direct_reports_cache[person_manager_uid] ||= Set.new
102
+ @direct_reports_cache[person_manager_uid].add entry
103
+ end
104
+
105
+ Entitlements.logger.debug "Built #{self.class} direct_reports_cache"
106
+
107
+ @direct_reports_cache
108
+ end
109
+
110
+ # Iterate through the list of managers and build up the lists of direct and indirect reports for each
111
+ # manager. Cache this so that the iteration only occurs one time.
112
+ #
113
+ # Returns a Hash of { "dn" => Set(Entitlements::Models::Person) }
114
+ Contract C::None => C::HashOf[String => C::SetOf[Entitlements::Models::Person]]
115
+ def all_reports_cache
116
+ return @all_reports_cache if @all_reports_cache
117
+
118
+ Entitlements.logger.debug "Building #{self.class} all_reports_cache"
119
+
120
+ @all_reports_cache = {}
121
+ direct_reports_cache.keys.each do |manager_uid|
122
+ generate_recursive_reports(manager_uid.downcase)
123
+ end
124
+
125
+ Entitlements.logger.debug "Built #{self.class} all_reports_cache"
126
+
127
+ @all_reports_cache
128
+ end
129
+
130
+ # Recursive method to determine all reports (direct and indirect). Intended to be called
131
+ # from `reports` method, when @direct_reports_cache has been populated but @all_reports_cache
132
+ # has not yet been initialized. This will populate @all_reports_cache. This hits each manager
133
+ # only once - O(N).
134
+ #
135
+ # manager_uid - A String with the uid of the manager.
136
+ #
137
+ # No return value.
138
+ Contract String => nil
139
+ def generate_recursive_reports(manager_uid)
140
+ # If we've calculated it already, then just return early
141
+ return if @all_reports_cache.key?(manager_uid)
142
+
143
+ # We've visited, so start the entry for it.
144
+ @all_reports_cache[manager_uid] = Set.new
145
+
146
+ # Add each direct report, as well as that person's direct reports, and so on.
147
+ direct_reports_cache[manager_uid].each do |direct_report|
148
+ # The direct report is also an "all" report for their manager.
149
+ @all_reports_cache[manager_uid].add direct_report
150
+ direct_report_uid = direct_report.uid.downcase
151
+
152
+ # If the direct report has no reports of their own, no need to go any further.
153
+ next unless direct_reports_cache.key?(direct_report_uid)
154
+
155
+ # Call this method again, this time with the direct report as the key. If we've already
156
+ # calculated all descendant reports of this person, it'll return immediately. Otherwise
157
+ # it'll iterate through this logic again, ensuring that we only calculate descendants
158
+ # once for any given person.
159
+ generate_recursive_reports(direct_report_uid)
160
+
161
+ # Merge in the calculated result for all of this report's descendants.
162
+ @all_reports_cache[manager_uid].merge @all_reports_cache[direct_report_uid]
163
+ end
164
+
165
+ # Satisfy the contract for return value.
166
+ nil
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end