first commit

This commit is contained in:
Nassim Babaci 2014-05-23 14:49:48 +02:00
commit d938e0fb91
4 changed files with 1421 additions and 0 deletions

17
swiftpolicy/__init__.py Normal file
View File

@ -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]

279
swiftpolicy/enforcer.py Normal file
View File

@ -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": ""'
'}'
)

271
swiftpolicy/keystoneauth.py Normal file
View File

@ -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

854
swiftpolicy/policy.py Normal file
View File

@ -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:<some_value>
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::
<some_value>: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::
<some_value>: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,
<a_number>) 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)