0 | |
# -*- coding: utf-8 -*-
|
1 | |
#
|
2 | |
# Copyright (c) 2012 OpenStack Foundation.
|
3 | |
# All Rights Reserved.
|
4 | |
#
|
5 | |
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
6 | |
# not use this file except in compliance with the License. You may obtain
|
7 | |
# a copy of the License at
|
8 | |
#
|
9 | |
# http://www.apache.org/licenses/LICENSE-2.0
|
10 | |
#
|
11 | |
# Unless required by applicable law or agreed to in writing, software
|
12 | |
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
13 | |
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
14 | |
# License for the specific language governing permissions and limitations
|
15 | |
# under the License.
|
16 | |
|
17 | |
"""
|
18 | |
Common Policy Engine Implementation
|
19 | |
|
20 | |
Policies can be expressed in one of two forms: A list of lists, or a
|
21 | |
string written in the new policy language.
|
22 | |
|
23 | |
In the list-of-lists representation, each check inside the innermost
|
24 | |
list is combined as with an "and" conjunction--for that check to pass,
|
25 | |
all the specified checks must pass. These innermost lists are then
|
26 | |
combined as with an "or" conjunction. As an example, take the following
|
27 | |
rule, expressed in the list-of-lists representation::
|
28 | |
|
29 | |
[["role:admin"], ["project_id:%(project_id)s", "role:projectadmin"]]
|
30 | |
|
31 | |
This is the original way of expressing policies, but there now exists a
|
32 | |
new way: the policy language.
|
33 | |
|
34 | |
In the policy language, each check is specified the same way as in the
|
35 | |
list-of-lists representation: a simple "a:b" pair that is matched to
|
36 | |
the correct class to perform that check::
|
37 | |
|
38 | |
+===========================================================================+
|
39 | |
| TYPE | SYNTAX |
|
40 | |
+===========================================================================+
|
41 | |
|User's Role | role:admin |
|
42 | |
+---------------------------------------------------------------------------+
|
43 | |
|Rules already defined on policy | rule:admin_required |
|
44 | |
+---------------------------------------------------------------------------+
|
45 | |
|Against URL's¹ | http://my-url.org/check |
|
46 | |
+---------------------------------------------------------------------------+
|
47 | |
|User attributes² | project_id:%(target.project.id)s |
|
48 | |
+---------------------------------------------------------------------------+
|
49 | |
|Strings | <variable>:'xpto2035abc' |
|
50 | |
| | 'myproject':<variable> |
|
51 | |
+---------------------------------------------------------------------------+
|
52 | |
| | project_id:xpto2035abc |
|
53 | |
|Literals | domain_id:20 |
|
54 | |
| | True:%(user.enabled)s |
|
55 | |
+===========================================================================+
|
56 | |
|
57 | |
¹URL checking must return 'True' to be valid
|
58 | |
²User attributes (obtained through the token): user_id, domain_id or project_id
|
59 | |
|
60 | |
Conjunction operators are available, allowing for more expressiveness
|
61 | |
in crafting policies. So, in the policy language, the previous check in
|
62 | |
list-of-lists becomes::
|
63 | |
|
64 | |
role:admin or (project_id:%(project_id)s and role:projectadmin)
|
65 | |
|
66 | |
The policy language also has the "not" operator, allowing a richer
|
67 | |
policy rule::
|
68 | |
|
69 | |
project_id:%(project_id)s and not role:dunce
|
70 | |
|
71 | |
Attributes sent along with API calls can be used by the policy engine
|
72 | |
(on the right side of the expression), by using the following syntax::
|
73 | |
|
74 | |
<some_value>:%(user.id)s
|
75 | |
|
76 | |
Contextual attributes of objects identified by their IDs are loaded
|
77 | |
from the database. They are also available to the policy engine and
|
78 | |
can be checked through the `target` keyword::
|
79 | |
|
80 | |
<some_value>:%(target.role.name)s
|
81 | |
|
82 | |
Finally, two special policy checks should be mentioned; the policy
|
83 | |
check "@" will always accept an access, and the policy check "!" will
|
84 | |
always reject an access. (Note that if a rule is either the empty
|
85 | |
list ("[]") or the empty string, this is equivalent to the "@" policy
|
86 | |
check.) Of these, the "!" policy check is probably the most useful,
|
87 | |
as it allows particular rules to be explicitly disabled.
|
88 | |
"""
|
89 | |
|
90 | |
import abc
|
91 | |
import ast
|
92 | |
import copy
|
93 | |
import os
|
94 | |
import re
|
95 | |
|
96 | |
from oslo.config import cfg
|
97 | |
from oslo.serialization import jsonutils
|
98 | |
import six
|
99 | |
import six.moves.urllib.parse as urlparse
|
100 | |
import six.moves.urllib.request as urlrequest
|
101 | |
|
102 | |
from castellan.openstack.common import fileutils
|
103 | |
from castellan.openstack.common._i18n import _, _LE, _LI
|
104 | |
from castellan.openstack.common import log as logging
|
105 | |
|
106 | |
|
107 | |
policy_opts = [
|
108 | |
cfg.StrOpt('policy_file',
|
109 | |
default='policy.json',
|
110 | |
help=_('The JSON file that defines policies.')),
|
111 | |
cfg.StrOpt('policy_default_rule',
|
112 | |
default='default',
|
113 | |
help=_('Default rule. Enforced when a requested rule is not '
|
114 | |
'found.')),
|
115 | |
cfg.MultiStrOpt('policy_dirs',
|
116 | |
default=['policy.d'],
|
117 | |
help=_('Directories where policy configuration files are '
|
118 | |
'stored. They can be relative to any directory '
|
119 | |
'in the search path defined by the config_dir '
|
120 | |
'option, or absolute paths. The file defined by '
|
121 | |
'policy_file must exist for these directories to '
|
122 | |
'be searched.')),
|
123 | |
]
|
124 | |
|
125 | |
CONF = cfg.CONF
|
126 | |
CONF.register_opts(policy_opts)
|
127 | |
|
128 | |
LOG = logging.getLogger(__name__)
|
129 | |
|
130 | |
_checks = {}
|
131 | |
|
132 | |
|
133 | |
def list_opts():
|
134 | |
"""Entry point for oslo.config-generator."""
|
135 | |
return [(None, copy.deepcopy(policy_opts))]
|
136 | |
|
137 | |
|
138 | |
class PolicyNotAuthorized(Exception):
|
139 | |
|
140 | |
def __init__(self, rule):
|
141 | |
msg = _("Policy doesn't allow %s to be performed.") % rule
|
142 | |
super(PolicyNotAuthorized, self).__init__(msg)
|
143 | |
|
144 | |
|
145 | |
class Rules(dict):
|
146 | |
"""A store for rules. Handles the default_rule setting directly."""
|
147 | |
|
148 | |
@classmethod
|
149 | |
def load_json(cls, data, default_rule=None):
|
150 | |
"""Allow loading of JSON rule data."""
|
151 | |
|
152 | |
# Suck in the JSON data and parse the rules
|
153 | |
rules = dict((k, parse_rule(v)) for k, v in
|
154 | |
jsonutils.loads(data).items())
|
155 | |
|
156 | |
return cls(rules, default_rule)
|
157 | |
|
158 | |
def __init__(self, rules=None, default_rule=None):
|
159 | |
"""Initialize the Rules store."""
|
160 | |
|
161 | |
super(Rules, self).__init__(rules or {})
|
162 | |
self.default_rule = default_rule
|
163 | |
|
164 | |
def __missing__(self, key):
|
165 | |
"""Implements the default rule handling."""
|
166 | |
|
167 | |
if isinstance(self.default_rule, dict):
|
168 | |
raise KeyError(key)
|
169 | |
|
170 | |
# If the default rule isn't actually defined, do something
|
171 | |
# reasonably intelligent
|
172 | |
if not self.default_rule:
|
173 | |
raise KeyError(key)
|
174 | |
|
175 | |
if isinstance(self.default_rule, BaseCheck):
|
176 | |
return self.default_rule
|
177 | |
|
178 | |
# We need to check this or we can get infinite recursion
|
179 | |
if self.default_rule not in self:
|
180 | |
raise KeyError(key)
|
181 | |
|
182 | |
elif isinstance(self.default_rule, six.string_types):
|
183 | |
return self[self.default_rule]
|
184 | |
|
185 | |
def __str__(self):
|
186 | |
"""Dumps a string representation of the rules."""
|
187 | |
|
188 | |
# Start by building the canonical strings for the rules
|
189 | |
out_rules = {}
|
190 | |
for key, value in self.items():
|
191 | |
# Use empty string for singleton TrueCheck instances
|
192 | |
if isinstance(value, TrueCheck):
|
193 | |
out_rules[key] = ''
|
194 | |
else:
|
195 | |
out_rules[key] = str(value)
|
196 | |
|
197 | |
# Dump a pretty-printed JSON representation
|
198 | |
return jsonutils.dumps(out_rules, indent=4)
|
199 | |
|
200 | |
|
201 | |
class Enforcer(object):
|
202 | |
"""Responsible for loading and enforcing rules.
|
203 | |
|
204 | |
:param policy_file: Custom policy file to use, if none is
|
205 | |
specified, `CONF.policy_file` will be
|
206 | |
used.
|
207 | |
:param rules: Default dictionary / Rules to use. It will be
|
208 | |
considered just in the first instantiation. If
|
209 | |
`load_rules(True)`, `clear()` or `set_rules(True)`
|
210 | |
is called this will be overwritten.
|
211 | |
:param default_rule: Default rule to use, CONF.default_rule will
|
212 | |
be used if none is specified.
|
213 | |
:param use_conf: Whether to load rules from cache or config file.
|
214 | |
:param overwrite: Whether to overwrite existing rules when reload rules
|
215 | |
from config file.
|
216 | |
"""
|
217 | |
|
218 | |
def __init__(self, policy_file=None, rules=None,
|
219 | |
default_rule=None, use_conf=True, overwrite=True):
|
220 | |
self.default_rule = default_rule or CONF.policy_default_rule
|
221 | |
self.rules = Rules(rules, self.default_rule)
|
222 | |
|
223 | |
self.policy_path = None
|
224 | |
self.policy_file = policy_file or CONF.policy_file
|
225 | |
self.use_conf = use_conf
|
226 | |
self.overwrite = overwrite
|
227 | |
|
228 | |
def set_rules(self, rules, overwrite=True, use_conf=False):
|
229 | |
"""Create a new Rules object based on the provided dict of rules.
|
230 | |
|
231 | |
:param rules: New rules to use. It should be an instance of dict.
|
232 | |
:param overwrite: Whether to overwrite current rules or update them
|
233 | |
with the new rules.
|
234 | |
:param use_conf: Whether to reload rules from cache or config file.
|
235 | |
"""
|
236 | |
|
237 | |
if not isinstance(rules, dict):
|
238 | |
raise TypeError(_("Rules must be an instance of dict or Rules, "
|
239 | |
"got %s instead") % type(rules))
|
240 | |
self.use_conf = use_conf
|
241 | |
if overwrite:
|
242 | |
self.rules = Rules(rules, self.default_rule)
|
243 | |
else:
|
244 | |
self.rules.update(rules)
|
245 | |
|
246 | |
def clear(self):
|
247 | |
"""Clears Enforcer rules, policy's cache and policy's path."""
|
248 | |
self.set_rules({})
|
249 | |
fileutils.delete_cached_file(self.policy_path)
|
250 | |
self.default_rule = None
|
251 | |
self.policy_path = None
|
252 | |
|
253 | |
def load_rules(self, force_reload=False):
|
254 | |
"""Loads policy_path's rules.
|
255 | |
|
256 | |
Policy file is cached and will be reloaded if modified.
|
257 | |
|
258 | |
:param force_reload: Whether to reload rules from config file.
|
259 | |
"""
|
260 | |
|
261 | |
if force_reload:
|
262 | |
self.use_conf = force_reload
|
263 | |
|
264 | |
if self.use_conf:
|
265 | |
if not self.policy_path:
|
266 | |
self.policy_path = self._get_policy_path(self.policy_file)
|
267 | |
|
268 | |
self._load_policy_file(self.policy_path, force_reload,
|
269 | |
overwrite=self.overwrite)
|
270 | |
for path in CONF.policy_dirs:
|
271 | |
try:
|
272 | |
path = self._get_policy_path(path)
|
273 | |
except cfg.ConfigFilesNotFoundError:
|
274 | |
LOG.info(_LI("Can not find policy directory: %s"), path)
|
275 | |
continue
|
276 | |
self._walk_through_policy_directory(path,
|
277 | |
self._load_policy_file,
|
278 | |
force_reload, False)
|
279 | |
|
280 | |
@staticmethod
|
281 | |
def _walk_through_policy_directory(path, func, *args):
|
282 | |
# We do not iterate over sub-directories.
|
283 | |
policy_files = next(os.walk(path))[2]
|
284 | |
policy_files.sort()
|
285 | |
for policy_file in [p for p in policy_files if not p.startswith('.')]:
|
286 | |
func(os.path.join(path, policy_file), *args)
|
287 | |
|
288 | |
def _load_policy_file(self, path, force_reload, overwrite=True):
|
289 | |
reloaded, data = fileutils.read_cached_file(
|
290 | |
path, force_reload=force_reload)
|
291 | |
if reloaded or not self.rules or not overwrite:
|
292 | |
rules = Rules.load_json(data, self.default_rule)
|
293 | |
self.set_rules(rules, overwrite=overwrite, use_conf=True)
|
294 | |
LOG.debug("Rules successfully reloaded")
|
295 | |
|
296 | |
def _get_policy_path(self, path):
|
297 | |
"""Locate the policy json data file/path.
|
298 | |
|
299 | |
:param path: It's value can be a full path or related path. When
|
300 | |
full path specified, this function just returns the full
|
301 | |
path. When related path specified, this function will
|
302 | |
search configuration directories to find one that exists.
|
303 | |
|
304 | |
:returns: The policy path
|
305 | |
|
306 | |
:raises: ConfigFilesNotFoundError if the file/path couldn't
|
307 | |
be located.
|
308 | |
"""
|
309 | |
policy_path = CONF.find_file(path)
|
310 | |
|
311 | |
if policy_path:
|
312 | |
return policy_path
|
313 | |
|
314 | |
raise cfg.ConfigFilesNotFoundError((path,))
|
315 | |
|
316 | |
def enforce(self, rule, target, creds, do_raise=False,
|
317 | |
exc=None, *args, **kwargs):
|
318 | |
"""Checks authorization of a rule against the target and credentials.
|
319 | |
|
320 | |
:param rule: A string or BaseCheck instance specifying the rule
|
321 | |
to evaluate.
|
322 | |
:param target: As much information about the object being operated
|
323 | |
on as possible, as a dictionary.
|
324 | |
:param creds: As much information about the user performing the
|
325 | |
action as possible, as a dictionary.
|
326 | |
:param do_raise: Whether to raise an exception or not if check
|
327 | |
fails.
|
328 | |
:param exc: Class of the exception to raise if the check fails.
|
329 | |
Any remaining arguments passed to enforce() (both
|
330 | |
positional and keyword arguments) will be passed to
|
331 | |
the exception class. If not specified, PolicyNotAuthorized
|
332 | |
will be used.
|
333 | |
|
334 | |
:return: Returns False if the policy does not allow the action and
|
335 | |
exc is not provided; otherwise, returns a value that
|
336 | |
evaluates to True. Note: for rules using the "case"
|
337 | |
expression, this True value will be the specified string
|
338 | |
from the expression.
|
339 | |
"""
|
340 | |
|
341 | |
self.load_rules()
|
342 | |
|
343 | |
# Allow the rule to be a Check tree
|
344 | |
if isinstance(rule, BaseCheck):
|
345 | |
result = rule(target, creds, self)
|
346 | |
elif not self.rules:
|
347 | |
# No rules to reference means we're going to fail closed
|
348 | |
result = False
|
349 | |
else:
|
350 | |
try:
|
351 | |
# Evaluate the rule
|
352 | |
result = self.rules[rule](target, creds, self)
|
353 | |
except KeyError:
|
354 | |
LOG.debug("Rule [%s] doesn't exist" % rule)
|
355 | |
# If the rule doesn't exist, fail closed
|
356 | |
result = False
|
357 | |
|
358 | |
# If it is False, raise the exception if requested
|
359 | |
if do_raise and not result:
|
360 | |
if exc:
|
361 | |
raise exc(*args, **kwargs)
|
362 | |
|
363 | |
raise PolicyNotAuthorized(rule)
|
364 | |
|
365 | |
return result
|
366 | |
|
367 | |
|
368 | |
@six.add_metaclass(abc.ABCMeta)
|
369 | |
class BaseCheck(object):
|
370 | |
"""Abstract base class for Check classes."""
|
371 | |
|
372 | |
@abc.abstractmethod
|
373 | |
def __str__(self):
|
374 | |
"""String representation of the Check tree rooted at this node."""
|
375 | |
|
376 | |
pass
|
377 | |
|
378 | |
@abc.abstractmethod
|
379 | |
def __call__(self, target, cred, enforcer):
|
380 | |
"""Triggers if instance of the class is called.
|
381 | |
|
382 | |
Performs the check. Returns False to reject the access or a
|
383 | |
true value (not necessary True) to accept the access.
|
384 | |
"""
|
385 | |
|
386 | |
pass
|
387 | |
|
388 | |
|
389 | |
class FalseCheck(BaseCheck):
|
390 | |
"""A policy check that always returns False (disallow)."""
|
391 | |
|
392 | |
def __str__(self):
|
393 | |
"""Return a string representation of this check."""
|
394 | |
|
395 | |
return "!"
|
396 | |
|
397 | |
def __call__(self, target, cred, enforcer):
|
398 | |
"""Check the policy."""
|
399 | |
|
400 | |
return False
|
401 | |
|
402 | |
|
403 | |
class TrueCheck(BaseCheck):
|
404 | |
"""A policy check that always returns True (allow)."""
|
405 | |
|
406 | |
def __str__(self):
|
407 | |
"""Return a string representation of this check."""
|
408 | |
|
409 | |
return "@"
|
410 | |
|
411 | |
def __call__(self, target, cred, enforcer):
|
412 | |
"""Check the policy."""
|
413 | |
|
414 | |
return True
|
415 | |
|
416 | |
|
417 | |
class Check(BaseCheck):
|
418 | |
"""A base class to allow for user-defined policy checks."""
|
419 | |
|
420 | |
def __init__(self, kind, match):
|
421 | |
"""Initiates Check instance.
|
422 | |
|
423 | |
:param kind: The kind of the check, i.e., the field before the
|
424 | |
':'.
|
425 | |
:param match: The match of the check, i.e., the field after
|
426 | |
the ':'.
|
427 | |
"""
|
428 | |
|
429 | |
self.kind = kind
|
430 | |
self.match = match
|
431 | |
|
432 | |
def __str__(self):
|
433 | |
"""Return a string representation of this check."""
|
434 | |
|
435 | |
return "%s:%s" % (self.kind, self.match)
|
436 | |
|
437 | |
|
438 | |
class NotCheck(BaseCheck):
|
439 | |
"""Implements the "not" logical operator.
|
440 | |
|
441 | |
A policy check that inverts the result of another policy check.
|
442 | |
"""
|
443 | |
|
444 | |
def __init__(self, rule):
|
445 | |
"""Initialize the 'not' check.
|
446 | |
|
447 | |
:param rule: The rule to negate. Must be a Check.
|
448 | |
"""
|
449 | |
|
450 | |
self.rule = rule
|
451 | |
|
452 | |
def __str__(self):
|
453 | |
"""Return a string representation of this check."""
|
454 | |
|
455 | |
return "not %s" % self.rule
|
456 | |
|
457 | |
def __call__(self, target, cred, enforcer):
|
458 | |
"""Check the policy.
|
459 | |
|
460 | |
Returns the logical inverse of the wrapped check.
|
461 | |
"""
|
462 | |
|
463 | |
return not self.rule(target, cred, enforcer)
|
464 | |
|
465 | |
|
466 | |
class AndCheck(BaseCheck):
|
467 | |
"""Implements the "and" logical operator.
|
468 | |
|
469 | |
A policy check that requires that a list of other checks all return True.
|
470 | |
"""
|
471 | |
|
472 | |
def __init__(self, rules):
|
473 | |
"""Initialize the 'and' check.
|
474 | |
|
475 | |
:param rules: A list of rules that will be tested.
|
476 | |
"""
|
477 | |
|
478 | |
self.rules = rules
|
479 | |
|
480 | |
def __str__(self):
|
481 | |
"""Return a string representation of this check."""
|
482 | |
|
483 | |
return "(%s)" % ' and '.join(str(r) for r in self.rules)
|
484 | |
|
485 | |
def __call__(self, target, cred, enforcer):
|
486 | |
"""Check the policy.
|
487 | |
|
488 | |
Requires that all rules accept in order to return True.
|
489 | |
"""
|
490 | |
|
491 | |
for rule in self.rules:
|
492 | |
if not rule(target, cred, enforcer):
|
493 | |
return False
|
494 | |
|
495 | |
return True
|
496 | |
|
497 | |
def add_check(self, rule):
|
498 | |
"""Adds rule to be tested.
|
499 | |
|
500 | |
Allows addition of another rule to the list of rules that will
|
501 | |
be tested. Returns the AndCheck object for convenience.
|
502 | |
"""
|
503 | |
|
504 | |
self.rules.append(rule)
|
505 | |
return self
|
506 | |
|
507 | |
|
508 | |
class OrCheck(BaseCheck):
|
509 | |
"""Implements the "or" operator.
|
510 | |
|
511 | |
A policy check that requires that at least one of a list of other
|
512 | |
checks returns True.
|
513 | |
"""
|
514 | |
|
515 | |
def __init__(self, rules):
|
516 | |
"""Initialize the 'or' check.
|
517 | |
|
518 | |
:param rules: A list of rules that will be tested.
|
519 | |
"""
|
520 | |
|
521 | |
self.rules = rules
|
522 | |
|
523 | |
def __str__(self):
|
524 | |
"""Return a string representation of this check."""
|
525 | |
|
526 | |
return "(%s)" % ' or '.join(str(r) for r in self.rules)
|
527 | |
|
528 | |
def __call__(self, target, cred, enforcer):
|
529 | |
"""Check the policy.
|
530 | |
|
531 | |
Requires that at least one rule accept in order to return True.
|
532 | |
"""
|
533 | |
|
534 | |
for rule in self.rules:
|
535 | |
if rule(target, cred, enforcer):
|
536 | |
return True
|
537 | |
return False
|
538 | |
|
539 | |
def add_check(self, rule):
|
540 | |
"""Adds rule to be tested.
|
541 | |
|
542 | |
Allows addition of another rule to the list of rules that will
|
543 | |
be tested. Returns the OrCheck object for convenience.
|
544 | |
"""
|
545 | |
|
546 | |
self.rules.append(rule)
|
547 | |
return self
|
548 | |
|
549 | |
|
550 | |
def _parse_check(rule):
|
551 | |
"""Parse a single base check rule into an appropriate Check object."""
|
552 | |
|
553 | |
# Handle the special checks
|
554 | |
if rule == '!':
|
555 | |
return FalseCheck()
|
556 | |
elif rule == '@':
|
557 | |
return TrueCheck()
|
558 | |
|
559 | |
try:
|
560 | |
kind, match = rule.split(':', 1)
|
561 | |
except Exception:
|
562 | |
LOG.exception(_LE("Failed to understand rule %s") % rule)
|
563 | |
# If the rule is invalid, we'll fail closed
|
564 | |
return FalseCheck()
|
565 | |
|
566 | |
# Find what implements the check
|
567 | |
if kind in _checks:
|
568 | |
return _checks[kind](kind, match)
|
569 | |
elif None in _checks:
|
570 | |
return _checks[None](kind, match)
|
571 | |
else:
|
572 | |
LOG.error(_LE("No handler for matches of kind %s") % kind)
|
573 | |
return FalseCheck()
|
574 | |
|
575 | |
|
576 | |
def _parse_list_rule(rule):
|
577 | |
"""Translates the old list-of-lists syntax into a tree of Check objects.
|
578 | |
|
579 | |
Provided for backwards compatibility.
|
580 | |
"""
|
581 | |
|
582 | |
# Empty rule defaults to True
|
583 | |
if not rule:
|
584 | |
return TrueCheck()
|
585 | |
|
586 | |
# Outer list is joined by "or"; inner list by "and"
|
587 | |
or_list = []
|
588 | |
for inner_rule in rule:
|
589 | |
# Elide empty inner lists
|
590 | |
if not inner_rule:
|
591 | |
continue
|
592 | |
|
593 | |
# Handle bare strings
|
594 | |
if isinstance(inner_rule, six.string_types):
|
595 | |
inner_rule = [inner_rule]
|
596 | |
|
597 | |
# Parse the inner rules into Check objects
|
598 | |
and_list = [_parse_check(r) for r in inner_rule]
|
599 | |
|
600 | |
# Append the appropriate check to the or_list
|
601 | |
if len(and_list) == 1:
|
602 | |
or_list.append(and_list[0])
|
603 | |
else:
|
604 | |
or_list.append(AndCheck(and_list))
|
605 | |
|
606 | |
# If we have only one check, omit the "or"
|
607 | |
if not or_list:
|
608 | |
return FalseCheck()
|
609 | |
elif len(or_list) == 1:
|
610 | |
return or_list[0]
|
611 | |
|
612 | |
return OrCheck(or_list)
|
613 | |
|
614 | |
|
615 | |
# Used for tokenizing the policy language
|
616 | |
_tokenize_re = re.compile(r'\s+')
|
617 | |
|
618 | |
|
619 | |
def _parse_tokenize(rule):
|
620 | |
"""Tokenizer for the policy language.
|
621 | |
|
622 | |
Most of the single-character tokens are specified in the
|
623 | |
_tokenize_re; however, parentheses need to be handled specially,
|
624 | |
because they can appear inside a check string. Thankfully, those
|
625 | |
parentheses that appear inside a check string can never occur at
|
626 | |
the very beginning or end ("%(variable)s" is the correct syntax).
|
627 | |
"""
|
628 | |
|
629 | |
for tok in _tokenize_re.split(rule):
|
630 | |
# Skip empty tokens
|
631 | |
if not tok or tok.isspace():
|
632 | |
continue
|
633 | |
|
634 | |
# Handle leading parens on the token
|
635 | |
clean = tok.lstrip('(')
|
636 | |
for i in range(len(tok) - len(clean)):
|
637 | |
yield '(', '('
|
638 | |
|
639 | |
# If it was only parentheses, continue
|
640 | |
if not clean:
|
641 | |
continue
|
642 | |
else:
|
643 | |
tok = clean
|
644 | |
|
645 | |
# Handle trailing parens on the token
|
646 | |
clean = tok.rstrip(')')
|
647 | |
trail = len(tok) - len(clean)
|
648 | |
|
649 | |
# Yield the cleaned token
|
650 | |
lowered = clean.lower()
|
651 | |
if lowered in ('and', 'or', 'not'):
|
652 | |
# Special tokens
|
653 | |
yield lowered, clean
|
654 | |
elif clean:
|
655 | |
# Not a special token, but not composed solely of ')'
|
656 | |
if len(tok) >= 2 and ((tok[0], tok[-1]) in
|
657 | |
[('"', '"'), ("'", "'")]):
|
658 | |
# It's a quoted string
|
659 | |
yield 'string', tok[1:-1]
|
660 | |
else:
|
661 | |
yield 'check', _parse_check(clean)
|
662 | |
|
663 | |
# Yield the trailing parens
|
664 | |
for i in range(trail):
|
665 | |
yield ')', ')'
|
666 | |
|
667 | |
|
668 | |
class ParseStateMeta(type):
|
669 | |
"""Metaclass for the ParseState class.
|
670 | |
|
671 | |
Facilitates identifying reduction methods.
|
672 | |
"""
|
673 | |
|
674 | |
def __new__(mcs, name, bases, cls_dict):
|
675 | |
"""Create the class.
|
676 | |
|
677 | |
Injects the 'reducers' list, a list of tuples matching token sequences
|
678 | |
to the names of the corresponding reduction methods.
|
679 | |
"""
|
680 | |
|
681 | |
reducers = []
|
682 | |
|
683 | |
for key, value in cls_dict.items():
|
684 | |
if not hasattr(value, 'reducers'):
|
685 | |
continue
|
686 | |
for reduction in value.reducers:
|
687 | |
reducers.append((reduction, key))
|
688 | |
|
689 | |
cls_dict['reducers'] = reducers
|
690 | |
|
691 | |
return super(ParseStateMeta, mcs).__new__(mcs, name, bases, cls_dict)
|
692 | |
|
693 | |
|
694 | |
def reducer(*tokens):
|
695 | |
"""Decorator for reduction methods.
|
696 | |
|
697 | |
Arguments are a sequence of tokens, in order, which should trigger running
|
698 | |
this reduction method.
|
699 | |
"""
|
700 | |
|
701 | |
def decorator(func):
|
702 | |
# Make sure we have a list of reducer sequences
|
703 | |
if not hasattr(func, 'reducers'):
|
704 | |
func.reducers = []
|
705 | |
|
706 | |
# Add the tokens to the list of reducer sequences
|
707 | |
func.reducers.append(list(tokens))
|
708 | |
|
709 | |
return func
|
710 | |
|
711 | |
return decorator
|
712 | |
|
713 | |
|
714 | |
@six.add_metaclass(ParseStateMeta)
|
715 | |
class ParseState(object):
|
716 | |
"""Implement the core of parsing the policy language.
|
717 | |
|
718 | |
Uses a greedy reduction algorithm to reduce a sequence of tokens into
|
719 | |
a single terminal, the value of which will be the root of the Check tree.
|
720 | |
|
721 | |
Note: error reporting is rather lacking. The best we can get with
|
722 | |
this parser formulation is an overall "parse failed" error.
|
723 | |
Fortunately, the policy language is simple enough that this
|
724 | |
shouldn't be that big a problem.
|
725 | |
"""
|
726 | |
|
727 | |
def __init__(self):
|
728 | |
"""Initialize the ParseState."""
|
729 | |
|
730 | |
self.tokens = []
|
731 | |
self.values = []
|
732 | |
|
733 | |
def reduce(self):
|
734 | |
"""Perform a greedy reduction of the token stream.
|
735 | |
|
736 | |
If a reducer method matches, it will be executed, then the
|
737 | |
reduce() method will be called recursively to search for any more
|
738 | |
possible reductions.
|
739 | |
"""
|
740 | |
|
741 | |
for reduction, methname in self.reducers:
|
742 | |
if (len(self.tokens) >= len(reduction) and
|
743 | |
self.tokens[-len(reduction):] == reduction):
|
744 | |
# Get the reduction method
|
745 | |
meth = getattr(self, methname)
|
746 | |
|
747 | |
# Reduce the token stream
|
748 | |
results = meth(*self.values[-len(reduction):])
|
749 | |
|
750 | |
# Update the tokens and values
|
751 | |
self.tokens[-len(reduction):] = [r[0] for r in results]
|
752 | |
self.values[-len(reduction):] = [r[1] for r in results]
|
753 | |
|
754 | |
# Check for any more reductions
|
755 | |
return self.reduce()
|
756 | |
|
757 | |
def shift(self, tok, value):
|
758 | |
"""Adds one more token to the state. Calls reduce()."""
|
759 | |
|
760 | |
self.tokens.append(tok)
|
761 | |
self.values.append(value)
|
762 | |
|
763 | |
# Do a greedy reduce...
|
764 | |
self.reduce()
|
765 | |
|
766 | |
@property
|
767 | |
def result(self):
|
768 | |
"""Obtain the final result of the parse.
|
769 | |
|
770 | |
Raises ValueError if the parse failed to reduce to a single result.
|
771 | |
"""
|
772 | |
|
773 | |
if len(self.values) != 1:
|
774 | |
raise ValueError("Could not parse rule")
|
775 | |
return self.values[0]
|
776 | |
|
777 | |
@reducer('(', 'check', ')')
|
778 | |
@reducer('(', 'and_expr', ')')
|
779 | |
@reducer('(', 'or_expr', ')')
|
780 | |
def _wrap_check(self, _p1, check, _p2):
|
781 | |
"""Turn parenthesized expressions into a 'check' token."""
|
782 | |
|
783 | |
return [('check', check)]
|
784 | |
|
785 | |
@reducer('check', 'and', 'check')
|
786 | |
def _make_and_expr(self, check1, _and, check2):
|
787 | |
"""Create an 'and_expr'.
|
788 | |
|
789 | |
Join two checks by the 'and' operator.
|
790 | |
"""
|
791 | |
|
792 | |
return [('and_expr', AndCheck([check1, check2]))]
|
793 | |
|
794 | |
@reducer('and_expr', 'and', 'check')
|
795 | |
def _extend_and_expr(self, and_expr, _and, check):
|
796 | |
"""Extend an 'and_expr' by adding one more check."""
|
797 | |
|
798 | |
return [('and_expr', and_expr.add_check(check))]
|
799 | |
|
800 | |
@reducer('check', 'or', 'check')
|
801 | |
def _make_or_expr(self, check1, _or, check2):
|
802 | |
"""Create an 'or_expr'.
|
803 | |
|
804 | |
Join two checks by the 'or' operator.
|
805 | |
"""
|
806 | |
|
807 | |
return [('or_expr', OrCheck([check1, check2]))]
|
808 | |
|
809 | |
@reducer('or_expr', 'or', 'check')
|
810 | |
def _extend_or_expr(self, or_expr, _or, check):
|
811 | |
"""Extend an 'or_expr' by adding one more check."""
|
812 | |
|
813 | |
return [('or_expr', or_expr.add_check(check))]
|
814 | |
|
815 | |
@reducer('not', 'check')
|
816 | |
def _make_not_expr(self, _not, check):
|
817 | |
"""Invert the result of another check."""
|
818 | |
|
819 | |
return [('check', NotCheck(check))]
|
820 | |
|
821 | |
|
822 | |
def _parse_text_rule(rule):
|
823 | |
"""Parses policy to the tree.
|
824 | |
|
825 | |
Translates a policy written in the policy language into a tree of
|
826 | |
Check objects.
|
827 | |
"""
|
828 | |
|
829 | |
# Empty rule means always accept
|
830 | |
if not rule:
|
831 | |
return TrueCheck()
|
832 | |
|
833 | |
# Parse the token stream
|
834 | |
state = ParseState()
|
835 | |
for tok, value in _parse_tokenize(rule):
|
836 | |
state.shift(tok, value)
|
837 | |
|
838 | |
try:
|
839 | |
return state.result
|
840 | |
except ValueError:
|
841 | |
# Couldn't parse the rule
|
842 | |
LOG.exception(_LE("Failed to understand rule %s") % rule)
|
843 | |
|
844 | |
# Fail closed
|
845 | |
return FalseCheck()
|
846 | |
|
847 | |
|
848 | |
def parse_rule(rule):
|
849 | |
"""Parses a policy rule into a tree of Check objects."""
|
850 | |
|
851 | |
# If the rule is a string, it's in the policy language
|
852 | |
if isinstance(rule, six.string_types):
|
853 | |
return _parse_text_rule(rule)
|
854 | |
return _parse_list_rule(rule)
|
855 | |
|
856 | |
|
857 | |
def register(name, func=None):
|
858 | |
"""Register a function or Check class as a policy check.
|
859 | |
|
860 | |
:param name: Gives the name of the check type, e.g., 'rule',
|
861 | |
'role', etc. If name is None, a default check type
|
862 | |
will be registered.
|
863 | |
:param func: If given, provides the function or class to register.
|
864 | |
If not given, returns a function taking one argument
|
865 | |
to specify the function or class to register,
|
866 | |
allowing use as a decorator.
|
867 | |
"""
|
868 | |
|
869 | |
# Perform the actual decoration by registering the function or
|
870 | |
# class. Returns the function or class for compliance with the
|
871 | |
# decorator interface.
|
872 | |
def decorator(func):
|
873 | |
_checks[name] = func
|
874 | |
return func
|
875 | |
|
876 | |
# If the function or class is given, do the registration
|
877 | |
if func:
|
878 | |
return decorator(func)
|
879 | |
|
880 | |
return decorator
|
881 | |
|
882 | |
|
883 | |
@register("rule")
|
884 | |
class RuleCheck(Check):
|
885 | |
def __call__(self, target, creds, enforcer):
|
886 | |
"""Recursively checks credentials based on the defined rules."""
|
887 | |
|
888 | |
try:
|
889 | |
return enforcer.rules[self.match](target, creds, enforcer)
|
890 | |
except KeyError:
|
891 | |
# We don't have any matching rule; fail closed
|
892 | |
return False
|
893 | |
|
894 | |
|
895 | |
@register("role")
|
896 | |
class RoleCheck(Check):
|
897 | |
def __call__(self, target, creds, enforcer):
|
898 | |
"""Check that there is a matching role in the cred dict."""
|
899 | |
|
900 | |
return self.match.lower() in [x.lower() for x in creds['roles']]
|
901 | |
|
902 | |
|
903 | |
@register('http')
|
904 | |
class HttpCheck(Check):
|
905 | |
def __call__(self, target, creds, enforcer):
|
906 | |
"""Check http: rules by calling to a remote server.
|
907 | |
|
908 | |
This example implementation simply verifies that the response
|
909 | |
is exactly 'True'.
|
910 | |
"""
|
911 | |
|
912 | |
url = ('http:' + self.match) % target
|
913 | |
|
914 | |
# Convert instances of object() in target temporarily to
|
915 | |
# empty dict to avoid circular reference detection
|
916 | |
# errors in jsonutils.dumps().
|
917 | |
temp_target = copy.deepcopy(target)
|
918 | |
for key in target.keys():
|
919 | |
element = target.get(key)
|
920 | |
if type(element) is object:
|
921 | |
temp_target[key] = {}
|
922 | |
|
923 | |
data = {'target': jsonutils.dumps(temp_target),
|
924 | |
'credentials': jsonutils.dumps(creds)}
|
925 | |
post_data = urlparse.urlencode(data)
|
926 | |
f = urlrequest.urlopen(url, post_data)
|
927 | |
return f.read() == "True"
|
928 | |
|
929 | |
|
930 | |
@register(None)
|
931 | |
class GenericCheck(Check):
|
932 | |
def __call__(self, target, creds, enforcer):
|
933 | |
"""Check an individual match.
|
934 | |
|
935 | |
Matches look like:
|
936 | |
|
937 | |
tenant:%(tenant_id)s
|
938 | |
role:compute:admin
|
939 | |
True:%(user.enabled)s
|
940 | |
'Member':%(role.name)s
|
941 | |
"""
|
942 | |
|
943 | |
try:
|
944 | |
match = self.match % target
|
945 | |
except KeyError:
|
946 | |
# While doing GenericCheck if key not
|
947 | |
# present in Target return false
|
948 | |
return False
|
949 | |
|
950 | |
try:
|
951 | |
# Try to interpret self.kind as a literal
|
952 | |
leftval = ast.literal_eval(self.kind)
|
953 | |
except ValueError:
|
954 | |
try:
|
955 | |
kind_parts = self.kind.split('.')
|
956 | |
leftval = creds
|
957 | |
for kind_part in kind_parts:
|
958 | |
leftval = leftval[kind_part]
|
959 | |
except KeyError:
|
960 | |
return False
|
961 | |
return match == six.text_type(leftval)
|