1module MCollective
2  module Util
3    class ActionPolicy
4      attr_accessor :config, :allow_unconfigured, :configdir, :agent, :caller_id
5      attr_accessor :action, :groups, :enable_default, :default_name
6
7      def self.authorize(request)
8        ActionPolicy.new(request).authorize_request
9      end
10
11      def initialize(request)
12        @config = Config.instance
13        @agent = request.agent
14        @caller_id = request.caller
15        @action = request.action
16        @allow_unconfigured = !!(config.pluginconf.fetch('actionpolicy.allow_unconfigured', 'n') =~ /^1|y/i)
17        @enable_default = !!(config.pluginconf.fetch('actionpolicy.enable_default', 'n') =~ /^1|y/i)
18        @default_name = config.pluginconf.fetch('actionpolicy.default_name', 'default')
19        @configdir = @config.configdir
20        @groups = {}
21      end
22
23      # Performs request authorization
24      #
25      # @return [Boolean]
26      def authorize_request
27        # Lookup the policy file. If none exists and @allow_unconfigured
28        # is false the request gets denied.
29        policy_file = lookup_policy_file
30
31        # No policy file exists and allow_unconfigured is false
32        if !policy_file && !allow_unconfigured
33          deny('Could not load any valid policy files. Denying based on allow_unconfigured: %s' % allow_unconfigured)
34        # No policy exists but allow_unconfigured is true
35        elsif !(policy_file) && allow_unconfigured
36          Log.debug('Could not load any valid policy files. Allowing based on allow_unconfigured: %s' % allow_unconfigured)
37          return true
38        end
39
40        parse_group_file(lookup_groups_file)
41
42        # A policy file exists
43        parse_policy_file(policy_file)
44      end
45
46      # Parse and validate the policy file
47      #
48      # @param policy_file [String] path to the policy file
49      # @return [Boolean]
50      # @raize [RPCAborted] when the request does not pass the policy
51      def parse_policy_file(policy_file)
52        Log.debug('Parsing policyfile for %s: %s' % [agent, policy_file])
53        allow = allow_unconfigured
54
55        File.read(policy_file).each_line do |line|
56          next if line =~ /^(#.*|\s*)$/
57
58          if line =~ /^policy\s+default\s+(\w+)/
59            if $1 == 'allow'
60              allow = true
61            else
62              allow = false
63            end
64          elsif line =~ /^(allow|deny)\t+(.+?)\t+(.+?)\t+(.+?)(\t+(.+?))*$/
65            if check_policy($2, $3, $4, $6)
66              if $1 == 'allow'
67                return true
68              else
69                deny("Denying based on explicit 'deny' policy rule in policyfile: %s" % File.basename(policy_file))
70              end
71            end
72          else
73            Log.warn("Cannot parse policy %s line: %s" % [policy_file, line])
74          end
75        end
76
77        allow || deny("Denying based on default policy in %s" % File.basename(policy_file))
78      end
79
80      # Parses the group file into the `@groups` memory structure
81      #
82      # @param group_file [String] path to the groups file
83      # @return [Hash] parsed groups
84      def parse_group_file(group_file)
85        return unless group_file
86        return unless File.exist?(group_file)
87
88        unless File.readable?(group_file)
89          Log.warn("The group file %s exist but it is not readable" % group_file)
90          return
91        end
92
93        Log.debug("Parsing groups file %s" % group_file)
94
95        File.read(group_file).each_line do |line|
96          next if line =~ /^(#.*|\s*)$/
97
98          parts = line.chomp.split
99
100          if parts[0] =~ /^([\w\.\-]+)$/
101            next if parts[1..-1].empty?
102
103            groups[ parts[0] ] = parts[1..-1]
104          else
105            Log.warn("Group file line '%s' is not in the expected format of 'group_name caller_id caller_id caller_id'" % line.chomp)
106          end
107        end
108
109        groups
110      end
111
112      # Determines if any of the groups have the caller in them
113      #
114      # @param groups [String,nil] space seperated list of groups
115      # @return [Boolean]
116      def caller_in_groups?(group_names)
117        return false unless group_names
118
119        group_names.to_s.split.select do |group|
120          next unless group =~ /^([\w\.\-]+)$/
121
122          groups.fetch(group, []).include?(caller_id)
123        end.any?
124      end
125
126      # Determine if the caller is any of the callerids
127      #
128      # @param caller_ids [String] space seperated list of caller ids
129      # @return [Boolean]
130      def caller_in_callerids?(caller_ids)
131        return false unless caller_ids
132
133        caller_ids.to_s.include?(caller_id)
134      end
135
136      def action_in_actions?(actions)
137        actions.split.include?(action)
138      end
139
140      # Check if a request made by a caller matches the state defined in the policy
141      #
142      # @param rpccaller [String] the rpccaller as per the policy line
143      # @param action [String] the actions as per the policy line
144      # @param facts [String] the facts as per the policy line
145      # @param classes [String] the facts as per the policy line
146      # @return [Boolean]
147      def check_policy(rpccaller, actions, facts, classes)
148        # If we have a wildcard caller or the caller matches our policy line
149        # then continue else skip this policy line
150        return false unless rpccaller == '*' || caller_in_callerids?(rpccaller) || caller_in_groups?(rpccaller)
151
152        # If we have a wildcard actions list or the request action is in the list
153        # of actions in the policy line continue, else skip this policy line
154        return false unless actions == '*' || action_in_actions?(actions)
155
156        return parse_facts(facts) && parse_classes(classes) if classes
157
158        parse_compound(facts)
159      end
160
161      # Parses and validates the facts from a policy line
162      #
163      # @param facts [String] facts as per the policy line
164      # @return [Boolean]
165      def parse_facts(facts)
166        return true if facts == '*'
167
168        if is_compound?(facts)
169          return parse_compound(facts)
170        else
171          facts.split.each do |fact|
172            return false unless lookup_fact(fact)
173          end
174        end
175
176        true
177      end
178
179      # Parses and validates the classes from the policy line
180      #
181      # @param classes [String] classes as per the policy line
182      # @return [Boolean]
183      def parse_classes(classes)
184        return true if classes == '*'
185
186        if is_compound?(classes)
187          return parse_compound(classes)
188        else
189          classes.split.each do |klass|
190            return false unless lookup_class(klass)
191          end
192        end
193
194        true
195      end
196
197      # Parses and validates a fact from the policy line
198      #
199      # @param fact [String] a standard fact filter format fact
200      # @return [Boolean]
201      def lookup_fact(fact)
202        if fact =~ /(.+)(<|>|=|<=|>=)(.+)/
203          lv = $1
204          sym = $2
205          rv = $3
206
207          sym = '==' if sym == '='
208          return eval("'#{Util.get_fact(lv)}'#{sym}'#{rv}'")
209        else
210          Log.warn("Class found where fact was expected")
211          return false
212        end
213      end
214
215      # Parses a class expression and validates it
216      #
217      # @param klass [String] class name to lookup and validate
218      # @return [Boolean]
219      def lookup_class(klass)
220        if klass =~ /(.+)(<|>|=|<=|>=)(.+)/
221          Log.warn("Fact found where class was expected")
222          return false
223        else
224          return Util.has_cf_class?(klass)
225        end
226      end
227
228      # Looks up and validates either a class or a fact
229      #
230      # @param token [String] either a fact in fact filter format or a class
231      # @return [Boolean]
232      def lookup(token)
233        if token =~ /(.+)(<|>|=|<=|>=)(.+)/
234          return lookup_fact(token)
235        else
236          return lookup_class(token)
237        end
238      end
239
240      # Determines full path to the policy file
241      #
242      # Here we lookup the full path of the policy file. If the policyfile
243      # does not exist, we check to see if a default file was set and
244      # determine its full path. If no default file exists, or default was
245      # not specified, we return false.
246      #
247      # @return [String,Boolean] full file path else false
248      def lookup_policy_file
249        policy_file = File.join(@configdir, "policies", "#{agent}.policy")
250
251        Log.debug("Looking for policy in #{policy_file}")
252
253        return policy_file if File.exist?(policy_file)
254
255        if enable_default
256          default_file = File.join(configdir, "policies", "#{default_name}.policy")
257
258          Log.debug("Initial lookup failed: looking for policy in #{default_file}")
259
260          return default_file if File.exist?(default_file)
261        end
262
263        Log.debug('Could not find any policy files.')
264
265        false
266      end
267
268
269      # Determines full path to the groups file
270      #
271      # @return [String] path to the file
272      def lookup_groups_file
273        File.join(configdir, "policies", "groups")
274      end
275
276      # Evalute a compound statement and return its truth value
277      #
278      # @param statement [String] a standard compound filter string
279      # @return [Boolean]
280      def eval_statement(statement)
281        token_type = statement.keys.first
282        token_value = statement.values.first
283
284        return token_value if (token_type != 'statement' && token_type != 'fstatement')
285
286        if token_type == 'statement'
287            return lookup(token_value)
288        elsif token_type == 'fstatement'
289          begin
290            return Matcher.eval_compound_fstatement(token_value)
291          rescue => e
292            Log.warn("Could not call Data function in policy file: #{e}")
293            return false
294          end
295        end
296      end
297
298      # Determines if a string is a compound filter
299      #
300      # @param list [String] a standard compound filter string
301      # @return [Boolean]
302      def is_compound?(list)
303        list.split.each do |token|
304          if token =~ /^!|^not$|^or$|^and$|\(.+\)/
305            return true
306          end
307        end
308
309        false
310      end
311
312      # Parse and evaluate a compound filter string
313      #
314      # @param list [String] compound filter
315      # @return [Boolean]
316      def parse_compound(list)
317        stack = Matcher.create_compound_callstack(list)
318
319        begin
320          stack.map!{ |item| eval_statement(item) }
321        rescue => e
322          Log.debug(e.to_s)
323          return false
324        end
325
326        eval(stack.join(' '))
327      end
328
329      # Log and raise an appropriate error on deny
330      #
331      # @param logline [String] line to log in the log file
332      # @raise [RPCAborted] standard non specific failure error
333      def deny(logline)
334        Log.debug(logline)
335
336        raise(RPCAborted, 'You are not authorized to call this agent or action.')
337      end
338    end
339  end
340end
341