commit d938e0fb915324bce0f883b2f50130ca8e42f920 Author: Nassim Babaci Date: Fri May 23 14:49:48 2014 +0200 first commit diff --git a/swiftpolicy/__init__.py b/swiftpolicy/__init__.py new file mode 100644 index 0000000..b66fe8d --- /dev/null +++ b/swiftpolicy/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2014 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from keystoneauth import filter_factory + +__all__ = [filter_factory] diff --git a/swiftpolicy/enforcer.py b/swiftpolicy/enforcer.py new file mode 100644 index 0000000..0a10cd4 --- /dev/null +++ b/swiftpolicy/enforcer.py @@ -0,0 +1,279 @@ +# Copyright 2014 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from policy import register +from policy import Enforcer +from policy import Check +from policy import Rules +from string import Template + + +def get_enforcer(operators_roles, reseller_role, is_admin, logger, policy_file=None): + swift_operators = [role.strip() + for role in operators_roles.split(',')] + if policy_file: + return FileBasedEnforcer(policy_file, logger=logger) + else: + return DefaultEnforcer(swift_operators, reseller_role, is_admin, logger=logger) + + +class DefaultEnforcer(Enforcer): + def __init__(self, swift_operator, swift_reseller, is_admin=False, logger=None): + super(DefaultEnforcer, self).__init__(policy_file=None, rules=None, + default_rule=None) + + self.swift_operator = swift_operator + self.swift_reseller = swift_reseller + self.is_admin = is_admin + self.log = logger + + def _get_policy(self): + param = { + "reseller_admin": self.swift_reseller, + "operators": " or ".join(["role:%s" % role + for role in self.swift_operator]) + } + if self.is_admin: + template = default_policy_is_admin_tmpl + else: + template = default_policy_tmpl + + policy = template % param + return policy + + def load_rules(self, force_reload=False): + #import pdb; pdb.set_trace() + policy = self._get_policy() + rules = Rules.load_json(policy, self.default_rule) + self.set_rules(rules) + +class FileBasedEnforcer(Enforcer): + def __init__(self, policy_file, logger): + super(FileBasedEnforcer, self).__init__(policy_file=None, rules=None, + default_rule=None) + self.policy_file = policy_file + self.log = logger + + def _get_policy(self): + with open(self.policy_file, 'r') as policies: + policy = policies.read() + + return policy + + def load_rules(self, force_reload=False): + #import pdb; pdb.set_trace() + policy = self._get_policy() + try: + rules = Rules.load_json(policy, self.default_rule) + except ValueError as error: + raise + self.set_rules(rules) + + +@register("acl") +class AclCheck(Check): + @staticmethod + def _authorize_cross_tenant(user_id, user_name, + tenant_id, tenant_name, acls): + """Check cross-tenant ACLs. + + Match tenant:user, tenant and user could be its id, name or '*' + + :param user_id: The user id from the identity token. + :param user_name: The user name from the identity token. + :param tenant_id: The tenant ID from the identity token. + :param tenant_name: The tenant name from the identity token. + :param acls: The given container ACL. + + :returns: matched string if tenant(name/id/*):user(name/id/*) matches + the given ACL. + None otherwise. + + """ + for tenant in [tenant_id, tenant_name, '*']: + for user in [user_id, user_name, '*']: + s = '%s:%s' % (tenant, user) + if s in acls: + return True + return False + + @staticmethod + def _check_role(roles, acls): + # Check if we have the role in the acls and allow it + for user_role in roles: + if user_role in (r.lower() for r in acls): + #log_msg = 'user %s:%s allowed in ACL: %s authorizing' + #self.logger.debug(log_msg, tenant_name, user_name, + # user_role) + return True + return False + + @staticmethod + def _authorize_unconfirmed_identity(req, obj, referrers, acls): + """" + Perform authorization for access that does not require a + confirmed identity. + + :returns: A boolean if authorization is granted or denied. None if + a determination could not be made. + """ + # Allow container sync. + if (req.environ.get('swift_sync_key') + and (req.environ['swift_sync_key'] == + req.headers.get('x-container-sync-key', None)) + and 'x-timestamp' in req.headers): + #log_msg = 'allowing proxy %s for container-sync' + #self.logger.debug(log_msg, req.remote_addr) + return True + + # Check if referrer is allowed. + from swift.common.middleware import acl as swift_acl + if swift_acl.referrer_allowed(req.referer, referrers): + if obj or '.rlistings' in acls: + #log_msg = 'authorizing %s via referer ACL' + #self.logger.debug(log_msg, req.referrer) + return True + return False + + def __call__(self, target, creds, enforcer): + """ """ + user_id = creds.get("user_id", None) + user_name = creds.get("user_name", None) + tenant_id = creds.get("tenant_id", None) + tenant_name = creds.get("tenant_name", None) + roles = creds.get("roles", None) + + acls = target["acls"] + req = target["req"] + obj = target["object"] + referrers = target["referrers"] + + if self.match == "check_cross_tenant": + res = self._authorize_cross_tenant(user_id, user_name, + tenant_id, tenant_name, + acls) + + elif self.match == "check_roles": + res = self._check_role(roles, acls) + + elif self.match == "check_is_public": + res = self._authorize_unconfirmed_identity(req, obj, + referrers, acls) + + else: + raise ValueError("{match} not allowed for rule 'acl'". + format(match=self.match)) + + enforcer.log.debug("Rule '%s' evaluated to %s" % (self.match, res)) + return res + + +default_policy_tmpl = ( + '{' + '"is_anonymous": "identity:None",' + '"is_authenticated": "not rule:is_anonymous",' + '"swift_reseller": "(role:%(reseller_admin)s)",' + '"swift_operator": "%(operators)s",' + + '"swift_owner": "rule:swift_reseller' + ' or rule:swift_operator",' + + '"reseller_request": "rule:swift_reseller",' + '"same_tenant": "account:%%(account)s",' + '"tenant_mismatch": "not rule:same_tenant",' + + '"allowed_for_authenticated": "rule:swift_reseller' + ' or acl:check_cross_tenant' + ' or acl:check_is_public' + ' or (rule:same_tenant and rule:swift_operator)' + ' or (rule:same_tenant and acl:check_roles)",' + + '"allowed_for_anonymous": "is_authoritative:True' + ' and acl:check_is_public",' + + '"allowed_for_user": "(rule:is_authenticated' + ' and rule:allowed_for_authenticated)' + ' or rule:allowed_for_anonymous",' + + '"get_account": "rule:allowed_for_user",' + '"post_account": "rule:allowed_for_user",' + '"head_account": "rule:allowed_for_user",' + '"delete_account": "rule:swift_reseller",' + '"options_account": "",' + '"get_container": "rule:allowed_for_user",' + '"put_container": "rule:allowed_for_user",' + '"delete_container": "rule:allowed_for_user",' + '"post_container": "rule:allowed_for_user",' + '"head_container": "rule:allowed_for_user",' + '"options_container": "",' + '"get_object": "rule:allowed_for_user",' + '"put_object": "rule:allowed_for_user",' + '"copy_object": "rule:allowed_for_user",' + '"delete_object": "rule:allowed_for_user",' + '"head_object": "rule:allowed_for_user",' + '"post_object": "rule:allowed_for_user",' + '"options_object": ""' + '}' +) + +default_policy_is_admin_tmpl = ( + '{' + '"is_anonymous": "identity:None",' + '"is_authenticated": "not rule:is_anonymous",' + '"swift_reseller": "(role:%(reseller_admin)s)",' + '"swift_operator": "%(operators)s",' + + '"swift_owner": "rule:swift_reseller' + ' or rule:swift_operator' + # diff: add is_admin to swift_owner + ' or is_admin:True",' + + '"reseller_request": "rule:swift_reseller",' + '"same_tenant": "account:%%(account)s",' + '"tenant_mismatch": "not rule:same_tenant",' + + '"allowed_for_authenticated": "rule:swift_reseller' + ' or acl:check_cross_tenant or acl:check_is_public' + ' or (rule:same_tenant and rule:swift_operator)' + # diff: allow access if user is_admin + ' or (rule:same_tenant and is_admin:True)' + ' or (rule:same_tenant and is_admin:False and acl:check_roles)",' + + '"allowed_for_anonymous": "is_authoritative:True' + ' and acl:check_is_public",' + + '"allowed_for_user": "(rule:is_authenticated' + ' and rule:allowed_for_authenticated)' + ' or rule:allowed_for_anonymous",' + + '"get_account": "rule:allowed_for_user",' + '"post_account": "rule:allowed_for_user",' + '"head_account": "rule:allowed_for_user",' + '"delete_account": "rule:swift_reseller",' + '"options_account": "",' + '"get_container": "rule:allowed_for_user",' + '"put_container": "rule:allowed_for_user",' + '"delete_container": "rule:allowed_for_user",' + '"post_container": "rule:allowed_for_user",' + '"head_container": "rule:allowed_for_user",' + '"options_container": "",' + '"get_object": "rule:allowed_for_user",' + '"put_object": "rule:allowed_for_user",' + '"copy_object": "rule:allowed_for_user",' + '"delete_object": "rule:allowed_for_user",' + '"head_object": "rule:allowed_for_user",' + '"post_object": "rule:allowed_for_user",' + '"options_object": ""' + '}' +) diff --git a/swiftpolicy/keystoneauth.py b/swiftpolicy/keystoneauth.py new file mode 100644 index 0000000..70b3b78 --- /dev/null +++ b/swiftpolicy/keystoneauth.py @@ -0,0 +1,271 @@ +# Copyright 2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from swift.common import utils as swift_utils +from swift.common.middleware import acl as swift_acl +from swift.common.swob import HTTPNotFound, HTTPForbidden, HTTPUnauthorized +from swift.common.swob import Request +from swift.common.utils import register_swift_info +from enforcer import get_enforcer + + +class KeystoneAuth(object): + """Swift middleware to Keystone authorization system. + + In Swift's proxy-server.conf add this middleware to your pipeline:: + + [pipeline:main] + pipeline = catch_errors cache authtoken keystoneauth proxy-server + + Make sure you have the authtoken middleware before the + keystoneauth middleware. + + The authtoken middleware will take care of validating the user and + keystoneauth will authorize access. + + The authtoken middleware is shipped directly with keystone it + does not have any other dependences than itself so you can either + install it by copying the file directly in your python path or by + installing keystone. + + If support is required for unvalidated users (as with anonymous + access) or for formpost/staticweb/tempurl middleware, authtoken will + need to be configured with ``delay_auth_decision`` set to true. See + the Keystone documentation for more detail on how to configure the + authtoken middleware. + + In proxy-server.conf you will need to have the setting account + auto creation to true:: + + [app:proxy-server] + account_autocreate = true + + And add a swift authorization filter section, such as:: + + [filter:keystoneauth] + use = egg:swift#keystoneauth + operator_roles = admin, swiftoperator + + This maps tenants to account in Swift. + + The user whose able to give ACL / create Containers permissions + will be the one that are inside the ``operator_roles`` + setting which by default includes the admin and the swiftoperator + roles. + + If you need to have a different reseller_prefix to be able to + mix different auth servers you can configure the option + ``reseller_prefix`` in your keystoneauth entry like this:: + + reseller_prefix = NEWAUTH + + :param app: The next WSGI app in the pipeline + :param conf: The dict of configuration values + """ + def __init__(self, app, conf): + self.app = app + self.conf = conf + self.logger = swift_utils.get_logger(conf, log_route='keystoneauth') + self.reseller_prefix = conf.get('reseller_prefix', 'AUTH_').strip() + if self.reseller_prefix and self.reseller_prefix[-1] != '_': + self.reseller_prefix += '_' + self.operator_roles = conf.get('operator_roles', + 'admin, swiftoperator').lower() + self.reseller_admin_role = conf.get('reseller_admin_role', + 'ResellerAdmin').lower() + config_is_admin = conf.get('is_admin', "false").lower() + self.is_admin = swift_utils.config_true_value(config_is_admin) + config_overrides = conf.get('allow_overrides', 't').lower() + self.allow_overrides = swift_utils.config_true_value(config_overrides) + self.policy_file = conf.get('policy', None) + + def __call__(self, environ, start_response): + identity = self._keystone_identity(environ) + + # Check if one of the middleware like tempurl or formpost have + # set the swift.authorize_override environ and want to control the + # authentication + if (self.allow_overrides and + environ.get('swift.authorize_override', False)): + msg = 'Authorizing from an overriding middleware (i.e: tempurl)' + self.logger.debug(msg) + return self.app(environ, start_response) + + if identity: + self.logger.debug('Using identity: %r', identity) + environ['keystone.identity'] = identity + environ['REMOTE_USER'] = identity.get('tenant') + environ['swift.authorize'] = self.authorize + # Check reseller_request again poicy + if self.check_action('reseller_request', environ): + environ['reseller_request'] = True + else: + self.logger.debug('Authorizing as anonymous') + environ['swift.authorize'] = self.authorize + + environ['swift.clean_acl'] = swift_acl.clean_acl + + return self.app(environ, start_response) + + def _keystone_identity(self, environ): + """Extract the identity from the Keystone auth component.""" + # In next release, we would add user id in env['keystone.identity'] by + # using _integral_keystone_identity to replace current + # _keystone_identity. The purpose of keeping it in this release it for + # back compatibility. + if environ.get('HTTP_X_IDENTITY_STATUS') != 'Confirmed': + return + roles = [] + if 'HTTP_X_ROLES' in environ: + roles = environ['HTTP_X_ROLES'].split(',') + identity = {'user': environ.get('HTTP_X_USER_NAME'), + 'tenant': (environ.get('HTTP_X_TENANT_ID'), + environ.get('HTTP_X_TENANT_NAME')), + 'roles': roles} + return identity + + def _integral_keystone_identity(self, environ): + """Extract the identity from the Keystone auth component.""" + if environ.get('HTTP_X_IDENTITY_STATUS') != 'Confirmed': + return + roles = [] + if 'HTTP_X_ROLES' in environ: + roles = environ['HTTP_X_ROLES'].split(',') + identity = {'user': (environ.get('HTTP_X_USER_ID'), + environ.get('HTTP_X_USER_NAME')), + 'tenant': (environ.get('HTTP_X_TENANT_ID'), + environ.get('HTTP_X_TENANT_NAME')), + 'roles': roles} + return identity + + def _get_account_for_tenant(self, tenant_id): + return '%s%s' % (self.reseller_prefix, tenant_id) + + def get_creds(self, environ): + req = Request(environ) + try: + parts = req.split_path(1, 4, True) + _, account, _, _ = parts + except ValueError: + account = None + + env_identity = self._integral_keystone_identity(environ) + if not env_identity: + # user identity is not confirmed. (anonymous?) + creds = { + 'identity': None, + 'is_authoritative': (account and + account.startswith(self.reseller_prefix)) + } + return creds + + tenant_id, tenant_name = env_identity['tenant'] + user_id, user_name = env_identity['user'] + roles = [r.strip() for r in env_identity.get('roles', [])] + account = self._get_account_for_tenant(tenant_id) + is_admin = (tenant_name == user_name) + + creds = { + "identity": env_identity, + "roles": roles, + "account": account, + "tenant_id": tenant_id, + "tenant_name": tenant_name, + "user_id": user_id, + "user_name": user_name, + "is_admin": is_admin + } + return creds + + def get_target(self, environ): + req = Request(environ) + try: + parts = req.split_path(1, 4, True) + version, account, container, obj = parts + except ValueError: + version = account = container = obj = None + + referrers, acls = swift_acl.parse_acl(getattr(req, 'acl', None)) + target = { + "req": req, + "method": req.method.lower(), + "version": version, + "account": account, + "container": container, + "object": obj, + "acls": acls, + "referrers": referrers + } + return target + + @staticmethod + def get_action(method, parts): + version, account, container, obj = parts + action = method.lower() + "_" + if obj: + action += "object" + elif container: + action += "container" + elif account: + action += "account" + + return action + + def check_action(self, action, environ): + creds = self.get_creds(environ) + target = self.get_target(environ) + enforcer = get_enforcer(self.operator_roles, + self.reseller_admin_role, + self.is_admin, + self.logger, + self.policy_file) + self.logger.debug("enforce action '%s'", action) + return enforcer.enforce(action, target, creds) + + def authorize(self, req): + try: + parts = req.split_path(1, 4, True) + except ValueError: + return HTTPNotFound(request=req) + + env = req.environ + action = self.get_action(req.method, parts) + + if self.check_action(action, env): + if self.check_action("swift_owner", env): + req.environ['swift_owner'] = True + return + return self.denied_response(req) + + def denied_response(self, req): + """Deny WSGI Response. + + Returns a standard WSGI response callable with the status of 403 or 401 + depending on whether the REMOTE_USER is set or not. + """ + if req.remote_user: + return HTTPForbidden(request=req) + else: + return HTTPUnauthorized(request=req) + + +def filter_factory(global_conf, **local_conf): + """Returns a WSGI filter app for use with paste.deploy.""" + conf = global_conf.copy() + conf.update(local_conf) + register_swift_info('keystoneauth') + + def auth_filter(app): + return KeystoneAuth(app, conf) + return auth_filter diff --git a/swiftpolicy/policy.py b/swiftpolicy/policy.py new file mode 100644 index 0000000..e7f4227 --- /dev/null +++ b/swiftpolicy/policy.py @@ -0,0 +1,854 @@ +# Copyright (c) 2012 OpenStack Foundation. +# +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Common Policy Engine Implementation + +Based on: + https://github.com/openstack/oslo-incubator/blob/master + /openstack/common/policy.py + and adapted to remove dependency to oslo.cfg. + +Policies can be expressed in one of two forms: A list of lists, or a +string written in the new policy language. + +In the list-of-lists representation, each check inside the innermost +list is combined as with an "and" conjunction--for that check to pass, +all the specified checks must pass. These innermost lists are then +combined as with an "or" conjunction. This is the original way of +expressing policies, but there now exists a new way: the policy +language. + +In the policy language, each check is specified the same way as in the +list-of-lists representation: a simple "a:b" pair that is matched to +the correct code to perform that check. However, conjunction +operators are available, allowing for more expressiveness in crafting +policies. + +As an example, take the following rule, expressed in the list-of-lists +representation:: + + [["role:admin"], ["project_id:%(project_id)s", "role:projectadmin"]] + +In the policy language, this becomes:: + + role:admin or (project_id:%(project_id)s and role:projectadmin) + +The policy language also has the "not" operator, allowing a richer +policy rule:: + + project_id:%(project_id)s and not role:dunce + +It is possible to perform policy checks on the following user +attributes (obtained through the token): user_id, domain_id or +project_id:: + + domain_id: + +Attributes sent along with API calls can be used by the policy engine +(on the right side of the expression), by using the following syntax:: + + :user.id + +Contextual attributes of objects identified by their IDs are loaded +from the database. They are also available to the policy engine and +can be checked through the `target` keyword:: + + :target.role.name + +All these attributes (related to users, API calls, and context) can be +checked against each other or against constants, be it literals (True, +) or strings. + +Finally, two special policy checks should be mentioned; the policy +check "@" will always accept an access, and the policy check "!" will +always reject an access. (Note that if a rule is either the empty +list ("[]") or the empty string, this is equivalent to the "@" policy +check.) Of these, the "!" policy check is probably the most useful, +as it allows particular rules to be explicitly disabled. +""" + +import abc +import ast +import re + +import six +import six.moves.urllib.parse as urlparse +import six.moves.urllib.request as urlrequest + +from swift import gettext_ as _ +import json + + +_checks = {} + + +class PolicyNotAuthorized(Exception): + + def __init__(self, rule): + msg = _("Policy doesn't allow %s to be performed.") % rule + super(PolicyNotAuthorized, self).__init__(msg) + + +class Rules(dict): + """A store for rules. Handles the default_rule setting directly.""" + + @classmethod + def load_json(cls, data, default_rule=None): + """Allow loading of JSON rule data.""" + + # Suck in the JSON data and parse the rules + rules = dict((k, parse_rule(v)) for k, v in + json.loads(data).items()) + + return cls(rules, default_rule) + + def __init__(self, rules=None, default_rule=None): + """Initialize the Rules store.""" + + super(Rules, self).__init__(rules or {}) + self.default_rule = default_rule + + def __missing__(self, key): + """Implements the default rule handling.""" + + if isinstance(self.default_rule, dict): + raise KeyError(key) + + # If the default rule isn't actually defined, do something + # reasonably intelligent + if not self.default_rule: + raise KeyError(key) + + if isinstance(self.default_rule, BaseCheck): + return self.default_rule + + # We need to check this or we can get infinite recursion + if self.default_rule not in self: + raise KeyError(key) + + elif isinstance(self.default_rule, six.string_types): + return self[self.default_rule] + + def __str__(self): + """Dumps a string representation of the rules.""" + + # Start by building the canonical strings for the rules + out_rules = {} + for key, value in self.items(): + # Use empty string for singleton TrueCheck instances + if isinstance(value, TrueCheck): + out_rules[key] = '' + else: + out_rules[key] = str(value) + + # Dump a pretty-printed JSON representation + return json.dumps(out_rules, indent=4) + + +class Enforcer(object): + """Responsible for loading and enforcing rules. + + :param policy_file: Custom policy file to use, if none is + specified, `CONF.policy_file` will be + used. + :param rules: Default dictionary / Rules to use. It will be + considered just in the first instantiation. If + `load_rules(True)`, `clear()` or `set_rules(True)` + is called this will be overwritten. + :param default_rule: Default rule to use, CONF.default_rule will + be used if none is specified. + """ + + def __init__(self, policy_file=None, rules=None, + default_rule=None): + self.rules = Rules(rules, default_rule) + self.default_rule = default_rule + + self.policy_path = None + self.policy_file = policy_file + + def set_rules(self, rules, overwrite=True): + """Create a new Rules object based on the provided dict of rules. + + :param rules: New rules to use. It should be an instance of dict. + :param overwrite: Whether to overwrite current rules or update them + with the new rules. + :param use_conf: Whether to reload rules from cache or config file. + """ + + if not isinstance(rules, dict): + raise TypeError(_("Rules must be an instance of dict or Rules, " + "got %s instead") % type(rules)) + if overwrite: + self.rules = Rules(rules, self.default_rule) + else: + self.rules.update(rules) + + def clear(self): + """Clears Enforcer rules, policy's cache and policy's path.""" + self.set_rules({}) + self.default_rule = None + self.policy_path = None + + def load_rules(self, force_reload=False): + """Loads policy_path's rules. """ + raise NotImplemented + + def _get_policy_path(self): + """Locate the policy json data file.""" + raise NotImplemented + + def enforce(self, rule, target, creds, do_raise=False, + exc=None, *args, **kwargs): + """Checks authorization of a rule against the target and credentials. + + :param rule: A string or BaseCheck instance specifying the rule + to evaluate. + :param target: As much information about the object being operated + on as possible, as a dictionary. + :param creds: As much information about the user performing the + action as possible, as a dictionary. + :param do_raise: Whether to raise an exception or not if check + fails. + :param exc: Class of the exception to raise if the check fails. + Any remaining arguments passed to check() (both + positional and keyword arguments) will be passed to + the exception class. If not specified, PolicyNotAuthorized + will be used. + + :return: Returns False if the policy does not allow the action and + exc is not provided; otherwise, returns a value that + evaluates to True. Note: for rules using the "case" + expression, this True value will be the specified string + from the expression. + """ + + # NOTE(flaper87): Not logging target or creds to avoid + # potential security issues. + #LOG.debug("Rule %s will be now enforced" % rule) + + self.load_rules() + + # Allow the rule to be a Check tree + if isinstance(rule, BaseCheck): + result = rule(target, creds, self) + elif not self.rules: + # No rules to reference means we're going to fail closed + result = False + else: + try: + # Evaluate the rule + result = self.rules[rule](target, creds, self) + except KeyError: + #LOG.debug("Rule [%s] doesn't exist" % rule) + # If the rule doesn't exist, fail closed + result = False + + # If it is False, raise the exception if requested + if do_raise and not result: + if exc: + raise exc(*args, **kwargs) + + raise PolicyNotAuthorized(rule) + + return result + + +@six.add_metaclass(abc.ABCMeta) +class BaseCheck(object): + """Abstract base class for Check classes.""" + + @abc.abstractmethod + def __str__(self): + """String representation of the Check tree rooted at this node.""" + + pass + + @abc.abstractmethod + def __call__(self, target, cred, enforcer): + """Triggers if instance of the class is called. + + Performs the check. Returns False to reject the access or a + true value (not necessary True) to accept the access. + """ + + pass + + +class FalseCheck(BaseCheck): + """A policy check that always returns False (disallow).""" + + def __str__(self): + """Return a string representation of this check.""" + + return "!" + + def __call__(self, target, cred, enforcer): + """Check the policy.""" + + return False + + +class TrueCheck(BaseCheck): + """A policy check that always returns True (allow).""" + + def __str__(self): + """Return a string representation of this check.""" + + return "@" + + def __call__(self, target, cred, enforcer): + """Check the policy.""" + + return True + + +class Check(BaseCheck): + """A base class to allow for user-defined policy checks.""" + + def __init__(self, kind, match): + """Initiates Check instance. + + :param kind: The kind of the check, i.e., the field before the + ':'. + :param match: The match of the check, i.e., the field after + the ':'. + """ + + self.kind = kind + self.match = match + + def __str__(self): + """Return a string representation of this check.""" + + return "%s:%s" % (self.kind, self.match) + + +class NotCheck(BaseCheck): + """Implements the "not" logical operator. + + A policy check that inverts the result of another policy check. + """ + + def __init__(self, rule): + """Initialize the 'not' check. + + :param rule: The rule to negate. Must be a Check. + """ + + self.rule = rule + + def __str__(self): + """Return a string representation of this check.""" + + return "not %s" % self.rule + + def __call__(self, target, cred, enforcer): + """Check the policy. + + Returns the logical inverse of the wrapped check. + """ + + return not self.rule(target, cred, enforcer) + + +class AndCheck(BaseCheck): + """Implements the "and" logical operator. + + A policy check that requires that a list of other checks all return True. + """ + + def __init__(self, rules): + """Initialize the 'and' check. + + :param rules: A list of rules that will be tested. + """ + + self.rules = rules + + def __str__(self): + """Return a string representation of this check.""" + + return "(%s)" % ' and '.join(str(r) for r in self.rules) + + def __call__(self, target, cred, enforcer): + """Check the policy. + + Requires that all rules accept in order to return True. + """ + + for rule in self.rules: + if not rule(target, cred, enforcer): + return False + + return True + + def add_check(self, rule): + """Adds rule to be tested. + + Allows addition of another rule to the list of rules that will + be tested. Returns the AndCheck object for convenience. + """ + + self.rules.append(rule) + return self + + +class OrCheck(BaseCheck): + """Implements the "or" operator. + + A policy check that requires that at least one of a list of other + checks returns True. + """ + + def __init__(self, rules): + """Initialize the 'or' check. + + :param rules: A list of rules that will be tested. + """ + + self.rules = rules + + def __str__(self): + """Return a string representation of this check.""" + + return "(%s)" % ' or '.join(str(r) for r in self.rules) + + def __call__(self, target, cred, enforcer): + """Check the policy. + + Requires that at least one rule accept in order to return True. + """ + + for rule in self.rules: + if rule(target, cred, enforcer): + return True + return False + + def add_check(self, rule): + """Adds rule to be tested. + + Allows addition of another rule to the list of rules that will + be tested. Returns the OrCheck object for convenience. + """ + + self.rules.append(rule) + return self + + +def _parse_check(rule): + """Parse a single base check rule into an appropriate Check object.""" + + # Handle the special checks + if rule == '!': + return FalseCheck() + elif rule == '@': + return TrueCheck() + + try: + kind, match = rule.split(':', 1) + except Exception: + #LOG.exception(_LE("Failed to understand rule %s") % rule) + # If the rule is invalid, we'll fail closed + return FalseCheck() + + # Find what implements the check + if kind in _checks: + return _checks[kind](kind, match) + elif None in _checks: + return _checks[None](kind, match) + else: + #LOG.error(_LE("No handler for matches of kind %s") % kind) + return FalseCheck() + + +def _parse_list_rule(rule): + """Translates the old list-of-lists syntax into a tree of Check objects. + + Provided for backwards compatibility. + """ + + # Empty rule defaults to True + if not rule: + return TrueCheck() + + # Outer list is joined by "or"; inner list by "and" + or_list = [] + for inner_rule in rule: + # Elide empty inner lists + if not inner_rule: + continue + + # Handle bare strings + if isinstance(inner_rule, six.string_types): + inner_rule = [inner_rule] + + # Parse the inner rules into Check objects + and_list = [_parse_check(r) for r in inner_rule] + + # Append the appropriate check to the or_list + if len(and_list) == 1: + or_list.append(and_list[0]) + else: + or_list.append(AndCheck(and_list)) + + # If we have only one check, omit the "or" + if not or_list: + return FalseCheck() + elif len(or_list) == 1: + return or_list[0] + + return OrCheck(or_list) + + +# Used for tokenizing the policy language +_tokenize_re = re.compile(r'\s+') + + +def _parse_tokenize(rule): + """Tokenizer for the policy language. + + Most of the single-character tokens are specified in the + _tokenize_re; however, parentheses need to be handled specially, + because they can appear inside a check string. Thankfully, those + parentheses that appear inside a check string can never occur at + the very beginning or end ("%(variable)s" is the correct syntax). + """ + + for tok in _tokenize_re.split(rule): + # Skip empty tokens + if not tok or tok.isspace(): + continue + + # Handle leading parens on the token + clean = tok.lstrip('(') + for i in range(len(tok) - len(clean)): + yield '(', '(' + + # If it was only parentheses, continue + if not clean: + continue + else: + tok = clean + + # Handle trailing parens on the token + clean = tok.rstrip(')') + trail = len(tok) - len(clean) + + # Yield the cleaned token + lowered = clean.lower() + if lowered in ('and', 'or', 'not'): + # Special tokens + yield lowered, clean + elif clean: + # Not a special token, but not composed solely of ')' + if len(tok) >= 2 and ((tok[0], tok[-1]) in + [('"', '"'), ("'", "'")]): + # It's a quoted string + yield 'string', tok[1:-1] + else: + yield 'check', _parse_check(clean) + + # Yield the trailing parens + for i in range(trail): + yield ')', ')' + + +class ParseStateMeta(type): + """Metaclass for the ParseState class. + + Facilitates identifying reduction methods. + """ + + def __new__(mcs, name, bases, cls_dict): + """Create the class. + + Injects the 'reducers' list, a list of tuples matching token sequences + to the names of the corresponding reduction methods. + """ + + reducers = [] + + for key, value in cls_dict.items(): + if not hasattr(value, 'reducers'): + continue + for reduction in value.reducers: + reducers.append((reduction, key)) + + cls_dict['reducers'] = reducers + + return super(ParseStateMeta, mcs).__new__(mcs, name, bases, cls_dict) + + +def reducer(*tokens): + """Decorator for reduction methods. + + Arguments are a sequence of tokens, in order, which should trigger running + this reduction method. + """ + + def decorator(func): + # Make sure we have a list of reducer sequences + if not hasattr(func, 'reducers'): + func.reducers = [] + + # Add the tokens to the list of reducer sequences + func.reducers.append(list(tokens)) + + return func + + return decorator + + +@six.add_metaclass(ParseStateMeta) +class ParseState(object): + """Implement the core of parsing the policy language. + + Uses a greedy reduction algorithm to reduce a sequence of tokens into + a single terminal, the value of which will be the root of the Check tree. + + Note: error reporting is rather lacking. The best we can get with + this parser formulation is an overall "parse failed" error. + Fortunately, the policy language is simple enough that this + shouldn't be that big a problem. + """ + + def __init__(self): + """Initialize the ParseState.""" + + self.tokens = [] + self.values = [] + + def reduce(self): + """Perform a greedy reduction of the token stream. + + If a reducer method matches, it will be executed, then the + reduce() method will be called recursively to search for any more + possible reductions. + """ + + for reduction, methname in self.reducers: + if (len(self.tokens) >= len(reduction) and + self.tokens[-len(reduction):] == reduction): + # Get the reduction method + meth = getattr(self, methname) + + # Reduce the token stream + results = meth(*self.values[-len(reduction):]) + + # Update the tokens and values + self.tokens[-len(reduction):] = [r[0] for r in results] + self.values[-len(reduction):] = [r[1] for r in results] + + # Check for any more reductions + return self.reduce() + + def shift(self, tok, value): + """Adds one more token to the state. Calls reduce().""" + + self.tokens.append(tok) + self.values.append(value) + + # Do a greedy reduce... + self.reduce() + + @property + def result(self): + """Obtain the final result of the parse. + + Raises ValueError if the parse failed to reduce to a single result. + """ + + if len(self.values) != 1: + raise ValueError("Could not parse rule") + return self.values[0] + + @reducer('(', 'check', ')') + @reducer('(', 'and_expr', ')') + @reducer('(', 'or_expr', ')') + def _wrap_check(self, _p1, check, _p2): + """Turn parenthesized expressions into a 'check' token.""" + + return [('check', check)] + + @reducer('check', 'and', 'check') + def _make_and_expr(self, check1, _and, check2): + """Create an 'and_expr'. + + Join two checks by the 'and' operator. + """ + + return [('and_expr', AndCheck([check1, check2]))] + + @reducer('and_expr', 'and', 'check') + def _extend_and_expr(self, and_expr, _and, check): + """Extend an 'and_expr' by adding one more check.""" + + return [('and_expr', and_expr.add_check(check))] + + @reducer('check', 'or', 'check') + def _make_or_expr(self, check1, _or, check2): + """Create an 'or_expr'. + + Join two checks by the 'or' operator. + """ + + return [('or_expr', OrCheck([check1, check2]))] + + @reducer('or_expr', 'or', 'check') + def _extend_or_expr(self, or_expr, _or, check): + """Extend an 'or_expr' by adding one more check.""" + + return [('or_expr', or_expr.add_check(check))] + + @reducer('not', 'check') + def _make_not_expr(self, _not, check): + """Invert the result of another check.""" + + return [('check', NotCheck(check))] + + +def _parse_text_rule(rule): + """Parses policy to the tree. + + Translates a policy written in the policy language into a tree of + Check objects. + """ + + # Empty rule means always accept + if not rule: + return TrueCheck() + + # Parse the token stream + state = ParseState() + for tok, value in _parse_tokenize(rule): + state.shift(tok, value) + + try: + return state.result + except ValueError: + # Couldn't parse the rule + #LOG.exception(_LE("Failed to understand rule %r") % rule) + + # Fail closed + return FalseCheck() + + +def parse_rule(rule): + """Parses a policy rule into a tree of Check objects.""" + + # If the rule is a string, it's in the policy language + if isinstance(rule, six.string_types): + return _parse_text_rule(rule) + return _parse_list_rule(rule) + + +def register(name, func=None): + """Register a function or Check class as a policy check. + + :param name: Gives the name of the check type, e.g., 'rule', + 'role', etc. If name is None, a default check type + will be registered. + :param func: If given, provides the function or class to register. + If not given, returns a function taking one argument + to specify the function or class to register, + allowing use as a decorator. + """ + + # Perform the actual decoration by registering the function or + # class. Returns the function or class for compliance with the + # decorator interface. + def decorator(func): + _checks[name] = func + return func + + # If the function or class is given, do the registration + if func: + return decorator(func) + + return decorator + + +@register("rule") +class RuleCheck(Check): + def __call__(self, target, creds, enforcer): + """Recursively checks credentials based on the defined rules.""" + + try: + result = enforcer.rules[self.match](target, creds, enforcer) + enforcer.log.debug("Rule '%s' evaluated to %s" % (self.match, result)) + return result + except KeyError: + # We don't have any matching rule; fail closed + return False + + +@register("role") +class RoleCheck(Check): + def __call__(self, target, creds, enforcer): + """Check that there is a matching role in the cred dict.""" + + return self.match.lower() in [x.lower() for x in creds['roles']] + + +@register('http') +class HttpCheck(Check): + def __call__(self, target, creds, enforcer): + """Check http: rules by calling to a remote server. + + This example implementation simply verifies that the response + is exactly 'True'. + """ + + url = ('http:' + self.match) % target + data = {'target': json.dumps(target), + 'credentials': json.dumps(creds)} + post_data = urlparse.urlencode(data) + f = urlrequest.urlopen(url, post_data) + return f.read() == "True" + + +@register(None) +class GenericCheck(Check): + def __call__(self, target, creds, enforcer): + """Check an individual match. + + Matches look like: + + tenant:%(tenant_id)s + role:compute:admin + True:%(user.enabled)s + 'Member':%(role.name)s + """ + + # TODO(termie): do dict inspection via dot syntax + try: + match = self.match % target + except KeyError: + # While doing GenericCheck if key not + # present in Target return false + return False + + try: + # Try to interpret self.kind as a literal + leftval = ast.literal_eval(self.kind) + except ValueError: + try: + leftval = creds[self.kind] + except KeyError: + return False + return match == six.text_type(leftval)