entitlements-app 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
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