Add default policy in code for the plan resource

This adds the basic framework for registering and using default policy
rules. Rules should be defined and returned from a module in
karbor/policies/, and then added to the list in
karbor/policies/__init__.py.

The sample file about default policy will be generated as yaml using
cmd 'tox -e genpolicy' in this patch.

A new context.can() method has been added for policy enforcement of
registered rules. It has the same parameters as the enforce() method
currently being used.

The patch add default policy in code for  plan resource in karbor.

Partial-Implements: blueprint policy-in-code
Change-Id: I88ce31ee7cff9263055cfb51f6b5da5c333c50f2
This commit is contained in:
chenying 2017-09-26 09:42:05 +08:00
parent 2e0fda195a
commit 83f97e4c41
16 changed files with 503 additions and 60 deletions

View File

@ -0,0 +1,4 @@
To generate the sample policy.yaml file, run the following command from the top
level of the karbor directory:
tox -egenpolicy

View File

@ -0,0 +1,3 @@
[DEFAULT]
output_file = etc/policy.yaml.sample
namespace = karbor

View File

@ -1,16 +1,4 @@
{
"context_is_admin": "role:admin",
"admin_or_owner": "is_admin:True or project_id:%(project_id)s",
"default": "rule:admin_or_owner",
"admin_api": "is_admin:True",
"plan:create": "rule:admin_or_owner",
"plan:update": "rule:admin_or_owner",
"plan:delete": "rule:admin_or_owner",
"plan:get": "rule:admin_or_owner",
"plan:get_all": "rule:admin_or_owner",
"restore:create": "rule:admin_or_owner",
"restore:update": "rule:admin_or_owner",
"restore:get": "rule:admin_or_owner",

View File

@ -19,7 +19,6 @@ from oslo_utils import uuidutils
from webob import exc
import karbor
from karbor.api import common
from karbor.api.openstack import wsgi
from karbor.common import constants
@ -28,7 +27,7 @@ from karbor.i18n import _
from karbor import objects
from karbor.objects import base as objects_base
import karbor.policy
from karbor.policies import plans as plan_policy
from karbor.services.operationengine import api as operationengine_api
from karbor.services.protection import api as protection_api
from karbor import utils
@ -49,23 +48,6 @@ CONF.register_opt(query_plan_filters_opt)
LOG = logging.getLogger(__name__)
def check_policy(context, action, target_obj=None):
target = {
'project_id': context.project_id,
'user_id': context.user_id,
}
if isinstance(target_obj, objects_base.KarborObject):
# Turn object into dict so target.update can work
target.update(
target_obj.obj_to_primitive() or {})
else:
target.update(target_obj or {})
_action = 'plan:%s' % action
karbor.policy.enforce(context, _action, target)
class PlanViewBuilder(common.ViewBuilder):
"""Model a server API response as a python dictionary."""
@ -170,7 +152,7 @@ class PlansController(wsgi.Controller):
except exception.PlanNotFound as error:
raise exc.HTTPNotFound(explanation=error.msg)
check_policy(context, 'delete', plan)
context.can(plan_policy.DELETE_POLICY, target_obj=plan)
plan.destroy()
LOG.info("Delete plan request issued successfully.",
resource={'id': plan.id})
@ -205,7 +187,7 @@ class PlansController(wsgi.Controller):
def _get_all(self, context, marker=None, limit=None, sort_keys=None,
sort_dirs=None, filters=None, offset=None):
check_policy(context, 'get_all')
context.can(plan_policy.GET_ALL_POLICY)
if filters is None:
filters = {}
@ -253,7 +235,7 @@ class PlansController(wsgi.Controller):
LOG.debug('Create plan request body: %s', body)
context = req.environ['karbor.context']
check_policy(context, 'create')
context.can(plan_policy.CREATE_POLICY)
plan = body['plan']
LOG.debug('Create plan request plan: %s', plan)
@ -347,8 +329,7 @@ class PlansController(wsgi.Controller):
plan = self._plan_get(context, id)
except exception.PlanNotFound as error:
raise exc.HTTPNotFound(explanation=error.msg)
check_policy(context, 'update', plan)
context.can(plan_policy.UPDATE_POLICY, target_obj=plan)
self._plan_update(context, plan, update_dict)
plan.update(update_dict)
@ -363,7 +344,7 @@ class PlansController(wsgi.Controller):
plan = objects.Plan.get_by_id(context, plan_id)
try:
check_policy(context, 'get', plan)
context.can(plan_policy.GET_POLICY, target_obj=plan)
except exception.PolicyNotAuthorized:
# raise PlanNotFound instead to make sure karbor behaves
# as it used to

View File

@ -21,7 +21,9 @@ from oslo_context import context
from oslo_utils import timeutils
import six
from karbor import exception
from karbor.i18n import _
from karbor.objects import base as objects_base
from karbor import policy
CONF = cfg.CONF
@ -85,7 +87,7 @@ class RequestContext(context.RequestContext):
# when policy.check_is_admin invokes request logging
# to make it loggable.
if self.is_admin is None:
self.is_admin = policy.check_is_admin(self.roles, self)
self.is_admin = policy.check_is_admin(self)
elif self.is_admin and 'admin' not in self.roles:
self.roles.append('admin')
@ -143,6 +145,42 @@ class RequestContext(context.RequestContext):
kwargs = {k: values[k] for k in values if k in allowed_keys}
return cls(**kwargs)
def can(self, action, target_obj=None, fatal=True):
"""Verifies that the given action is valid on the target in this context.
:param action: string representing the action to be checked.
:param target: dictionary representing the object of the action
for object creation this should be a dictionary representing the
location of the object e.g. ``{'project_id': context.project_id}``.
If None, then this default target will be considered:
{'project_id': self.project_id, 'user_id': self.user_id}
:param: target_obj: dictionary representing the object which will be
used to update target.
:param fatal: if False, will return False when an
exception.NotAuthorized occurs.
:raises nova.exception.Forbidden: if verification fails and fatal is
True.
:return: returns a non-False value (not necessarily "True") if
authorized and False if not authorized and fatal is False.
"""
target = {'project_id': self.project_id,
'user_id': self.user_id}
if isinstance(target_obj, objects_base.KarborObject):
# Turn object into dict so target.update can work
target.update(
target_obj.obj_to_primitive()['karbor_object.data'] or {})
else:
target.update(target_obj or {})
try:
return policy.authorize(self, action, target)
except exception.NotAuthorized:
if fatal:
raise
return False
def to_policy_values(self):
policy = super(RequestContext, self).to_policy_values()

View File

@ -0,0 +1,25 @@
# 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.
import itertools
from karbor.policies import base
from karbor.policies import plans
def list_rules():
return itertools.chain(
base.list_rules(),
plans.list_rules()
)

34
karbor/policies/base.py Normal file
View File

@ -0,0 +1,34 @@
# 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.
from oslo_policy import policy
RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner'
RULE_ADMIN_API = 'rule:admin_api'
rules = [
policy.RuleDefault('context_is_admin', 'role:admin'),
policy.RuleDefault('admin_or_owner',
'is_admin:True or (role:admin and '
'is_admin_project:True) or project_id:%(project_id)s'),
policy.RuleDefault('default',
'rule:admin_or_owner'),
policy.RuleDefault('admin_api',
'is_admin:True or (role:admin and '
'is_admin_project:True)'),
]
def list_rules():
return rules

82
karbor/policies/plans.py Normal file
View File

@ -0,0 +1,82 @@
# Copyright (c) 2017 Huawei Technologies Co., Ltd.
# 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.
from oslo_policy import policy
from karbor.policies import base
CREATE_POLICY = 'plan:create'
UPDATE_POLICY = 'plan:update'
DELETE_POLICY = 'plan:delete'
GET_POLICY = 'plan:get'
GET_ALL_POLICY = 'plan:get_all'
plans_policies = [
policy.DocumentedRuleDefault(
name=CREATE_POLICY,
check_str=base.RULE_ADMIN_OR_OWNER,
description="""Create a plan.""",
operations=[
{
'method': 'POST',
'path': '/plans'
}
]),
policy.DocumentedRuleDefault(
name=UPDATE_POLICY,
check_str=base.RULE_ADMIN_OR_OWNER,
description="""Update a plan.""",
operations=[
{
'method': 'PUT',
'path': '/plans/{plan_id}'
}
]),
policy.DocumentedRuleDefault(
name=DELETE_POLICY,
check_str=base.RULE_ADMIN_OR_OWNER,
description="""Delete a plan.""",
operations=[
{
'method': 'DELETE',
'path': '/plans/{plan_id}'
}
]),
policy.DocumentedRuleDefault(
name=GET_POLICY,
check_str=base.RULE_ADMIN_OR_OWNER,
description="""Get a plan.""",
operations=[
{
'method': 'GET',
'path': '/plans/{plan_id}'
}
]),
policy.DocumentedRuleDefault(
name=GET_ALL_POLICY,
check_str=base.RULE_ADMIN_OR_OWNER,
description="""Get plans.""",
operations=[
{
'method': 'GET',
'path': '/plans'
}
]),
]
def list_rules():
return plans_policies

View File

@ -13,25 +13,54 @@
# License for the specific language governing permissions and limitations
# under the License.
"""Policy Engine For karbor"""
"""Policy Engine For Karbor"""
import sys
from oslo_config import cfg
from oslo_log import log as logging
from oslo_policy import opts as policy_opts
from oslo_policy import policy
from oslo_utils import excutils
from karbor import exception
from karbor import policies
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
policy_opts.set_defaults(cfg.CONF, 'policy.json')
_ENFORCER = None
def init():
def reset():
global _ENFORCER
if _ENFORCER:
_ENFORCER.clear()
_ENFORCER = None
def init(policy_file=None, rules=None, default_rule=None, use_conf=True):
"""Init an Enforcer class.
: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.
:param default_rule: Default rule to use, CONF.default_rule will
be used if none is specified.
:param use_conf: Whether to load rules from config file.
"""
global _ENFORCER
if not _ENFORCER:
_ENFORCER = policy.Enforcer(CONF)
_ENFORCER = policy.Enforcer(CONF,
policy_file=policy_file,
rules=rules,
default_rule=default_rule,
use_conf=use_conf)
register_rules(_ENFORCER)
_ENFORCER.load_rules()
def enforce_action(context, action):
@ -55,9 +84,9 @@ def enforce(context, action, target):
``compute:attach_volume``,
``volume:attach_volume``
:param target: dictionary representing the target of the action
for target creation this should be a dictionary representing the
location of the target e.g. ``{'project_id': context.project_id}``
:param target: dictionary representing the object of the action
for object creation this should be a dictionary representing the
location of the object e.g. ``{'project_id': context.project_id}``
:raises PolicyNotAuthorized: if verification fails.
@ -72,19 +101,100 @@ def enforce(context, action, target):
action=action)
def check_is_admin(roles, context=None):
def set_rules(rules, overwrite=True, use_conf=False):
"""Set rules 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 config file.
"""
init(use_conf=False)
_ENFORCER.set_rules(rules, overwrite, use_conf)
def get_rules():
if _ENFORCER:
return _ENFORCER.rules
def register_rules(enforcer):
enforcer.register_defaults(policies.list_rules())
def get_enforcer():
# This method is for use by oslopolicy CLI scripts. Those scripts need the
# 'output-file' and 'namespace' options, but having those in sys.argv means
# loading the Karbor config options will fail as those are not expected to
# be present. So we pass in an arg list with those stripped out.
conf_args = []
# Start at 1 because cfg.CONF expects the equivalent of sys.argv[1:]
i = 1
while i < len(sys.argv):
if sys.argv[i].strip('-') in ['namespace', 'output-file']:
i += 2
continue
conf_args.append(sys.argv[i])
i += 1
cfg.CONF(conf_args, project='karbor')
init()
return _ENFORCER
def authorize(context, action, target, do_raise=True, exc=None):
"""Verifies that the action is valid on the target in this context.
:param context: karbor context
:param action: string representing the action to be checked
this should be colon separated for clarity.
i.e. ``compute:create_instance``,
``plan:create``,
``plan:get``
:param target: dictionary representing the object of the action
for object creation this should be a dictionary representing the
location of the object e.g. ``{'project_id': context.project_id}``
:param do_raise: if True (the default), raises PolicyNotAuthorized;
if False, returns False
:param exc: Class of the exception to raise if the check fails.
Any remaining arguments passed to :meth:`authorize` (both
positional and keyword arguments) will be passed to
the exception class. If not specified,
:class:`PolicyNotAuthorized` will be used.
:raises karbor.exception.PolicyNotAuthorized: if verification fails
and do_raise is True. Or if 'exc' is specified it will raise an
exception of that type.
:return: returns a non-False value (not necessarily "True") if
authorized, and the exact value False if not authorized and
do_raise is False.
"""
init()
credentials = context.to_policy_values()
if not exc:
exc = exception.PolicyNotAuthorized
try:
result = _ENFORCER.authorize(action, target, credentials,
do_raise=do_raise, exc=exc, action=action)
except policy.PolicyNotRegistered:
with excutils.save_and_reraise_exception():
LOG.exception('Policy not registered')
except Exception:
with excutils.save_and_reraise_exception():
LOG.debug('Policy check for %(action)s failed with credentials '
'%(credentials)s',
{'action': action, 'credentials': credentials})
return result
def check_is_admin(context):
"""Whether or not user is admin according to policy setting.
"""
init()
# include project_id on target to avoid KeyError if context_is_admin
# policy definition is missing, and default admin_or_owner rule
# attempts to apply.
target = {'project_id': ''}
if context is None:
credentials = {'roles': roles}
else:
credentials = context.to_dict()
return _ENFORCER.enforce('context_is_admin', target, credentials)
# the target is user-self
credentials = context.to_policy_values()
target = credentials
return _ENFORCER.authorize('context_is_admin', target, credentials)

View File

@ -14,6 +14,7 @@ import logging
import os
import fixtures
import mock
from oslo_config import cfg
from oslo_messaging import conffixture as messaging_conffixture
from oslo_utils import timeutils
@ -73,6 +74,7 @@ class TestCase(base.BaseTestCase):
self.messaging_conf.transport_driver = 'fake'
self.messaging_conf.response_timeout = 15
self.useFixture(self.messaging_conf)
rpc.init(CONF)
conf_fixture.set_defaults(CONF)
@ -112,3 +114,17 @@ class TestCase(base.BaseTestCase):
"""Override CONF variables for a test."""
for k, v in kw.items():
self.override_config(k, v)
def mock_object(self, obj, attr_name, new_attr=None, **kwargs):
"""Use python mock to mock an object attribute
Mocks the specified objects attribute with the given value.
Automatically performs 'addCleanup' for the mock.
"""
if not new_attr:
new_attr = mock.Mock()
patcher = mock.patch.object(obj, attr_name, new_attr, **kwargs)
patcher.start()
self.addCleanup(patcher.stop)
return new_attr

View File

@ -38,6 +38,8 @@ class PlanApiTest(base.TestCase):
super(PlanApiTest, self).setUp()
self.controller = plans.PlansController()
self.ctxt = context.RequestContext('demo', 'fakeproject', True)
self.mock_policy_check = self.mock_object(
context.RequestContext, 'can')
@mock.patch(
'karbor.services.protection.rpcapi.ProtectionAPI.show_provider')
@ -50,6 +52,7 @@ class PlanApiTest(base.TestCase):
mock_provider.return_value = fakes.PROVIDER_OS
self.controller.create(req, body)
self.assertTrue(mock_plan_create.called)
self.assertTrue(self.mock_policy_check.called)
def test_plan_create_InvalidBody(self):
plan = self._plan_in_request_body()
@ -206,12 +209,10 @@ class PlanApiTest(base.TestCase):
exc.HTTPBadRequest, self.controller.delete,
req, "1")
@mock.patch(
'karbor.api.v1.plans.check_policy')
@mock.patch(
'karbor.api.v1.plans.PlansController._plan_get')
def test_plan_update_InvalidStatus(
self, mock_plan_get, mock_check_policy):
self, mock_plan_get):
plan = self._plan_in_request_body(
name=DEFAULT_NAME,
description=DEFAULT_DESCRIPTION,

View File

@ -187,9 +187,11 @@ class ScheduledOperationApiTest(base.TestCase):
req = fakes.HTTPRequest.blank('/v1/triggers')
return controller.create(req, create_trigger_param)
@mock.patch(
'karbor.context.RequestContext.can')
@mock.patch(
'karbor.services.protection.rpcapi.ProtectionAPI.show_provider')
def _create_plan(self, provider_id, mock_provider):
def _create_plan(self, provider_id, mock_provider, mock_policy):
create_plan_param = {
'plan': {
'name': '123',

View File

@ -0,0 +1,131 @@
# Copyright (c) 2017 Huawei Technologies Co., Ltd.
# 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.
import os.path
from oslo_config import cfg
from oslo_config import fixture as config_fixture
from oslo_policy import policy as oslo_policy
from karbor import context
from karbor import exception
from karbor.tests import base
from karbor import utils
from karbor import policy
CONF = cfg.CONF
class PolicyFileTestCase(base.TestCase):
def setUp(self):
super(PolicyFileTestCase, self).setUp()
self.context = context.get_admin_context()
self.target = {}
self.fixture = self.useFixture(config_fixture.Config(CONF))
self.addCleanup(policy.reset)
def test_modified_policy_reloads(self):
with utils.tempdir() as tmpdir:
tmpfilename = os.path.join(tmpdir, 'policy')
self.fixture.config(policy_file=tmpfilename, group='oslo_policy')
policy.reset()
policy.init()
rule = oslo_policy.RuleDefault('example:test', "")
policy._ENFORCER.register_defaults([rule])
action = "example:test"
with open(tmpfilename, "w") as policyfile:
policyfile.write('{"example:test": ""}')
policy.authorize(self.context, action, self.target)
with open(tmpfilename, "w") as policyfile:
policyfile.write('{"example:test": "!"}')
policy._ENFORCER.load_rules(True)
self.assertRaises(exception.PolicyNotAuthorized,
policy.authorize,
self.context, action, self.target)
class PolicyTestCase(base.TestCase):
def setUp(self):
super(PolicyTestCase, self).setUp()
rules = [
oslo_policy.RuleDefault("true", '@'),
oslo_policy.RuleDefault("test:allowed", '@'),
oslo_policy.RuleDefault("test:denied", "!"),
oslo_policy.RuleDefault("test:my_file",
"role:compute_admin or "
"project_id:%(project_id)s"),
oslo_policy.RuleDefault("test:early_and_fail", "! and @"),
oslo_policy.RuleDefault("test:early_or_success", "@ or !"),
oslo_policy.RuleDefault("test:lowercase_admin",
"role:admin"),
oslo_policy.RuleDefault("test:uppercase_admin",
"role:ADMIN"),
]
policy.reset()
policy.init()
# before a policy rule can be used, its default has to be registered.
policy._ENFORCER.register_defaults(rules)
self.context = context.RequestContext('fake', 'fake', roles=['member'])
self.target = {}
self.addCleanup(policy.reset)
def test_authorize_nonexistent_action_throws(self):
action = "test:noexist"
self.assertRaises(oslo_policy.PolicyNotRegistered, policy.authorize,
self.context, action, self.target)
def test_authorize_bad_action_throws(self):
action = "test:denied"
self.assertRaises(exception.PolicyNotAuthorized, policy.authorize,
self.context, action, self.target)
def test_authorize_bad_action_noraise(self):
action = "test:denied"
result = policy.authorize(self.context, action, self.target, False)
self.assertFalse(result)
def test_authorize_good_action(self):
action = "test:allowed"
result = policy.authorize(self.context, action, self.target)
self.assertTrue(result)
def test_templatized_authorization(self):
target_mine = {'project_id': 'fake'}
target_not_mine = {'project_id': 'another'}
action = "test:my_file"
policy.authorize(self.context, action, target_mine)
self.assertRaises(exception.PolicyNotAuthorized, policy.authorize,
self.context, action, target_not_mine)
def test_early_AND_authorization(self):
action = "test:early_and_fail"
self.assertRaises(exception.PolicyNotAuthorized, policy.authorize,
self.context, action, self.target)
def test_early_OR_authorization(self):
action = "test:early_or_success"
policy.authorize(self.context, action, self.target)
def test_ignore_case_role_check(self):
lowercase_action = "test:lowercase_admin"
uppercase_action = "test:uppercase_admin"
admin_context = context.RequestContext('admin',
'fake',
roles=['AdMiN'])
policy.authorize(admin_context, lowercase_action, self.target)
policy.authorize(admin_context, uppercase_action, self.target)

View File

@ -12,7 +12,11 @@
"""Utilities and helper functions."""
import ast
import contextlib
import os
import shutil
import six
import tempfile
import webob.exc
from keystoneclient import discover as ks_discover
@ -175,3 +179,16 @@ def walk_class_hierarchy(clazz, encountered=None):
for subsubclass in walk_class_hierarchy(subclass, encountered):
yield subsubclass
yield subclass
@contextlib.contextmanager
def tempdir(**kwargs):
tmpdir = tempfile.mkdtemp(**kwargs)
try:
yield tmpdir
finally:
try:
shutil.rmtree(tmpdir)
except OSError as e:
LOG.debug('Could not remove tmpdir: %s',
six.text_type(e))

View File

@ -30,6 +30,14 @@ console_scripts =
karbor-protection = karbor.cmd.protection:main
oslo.config.opts =
karbor.common.opts = karbor.common.opts:list_opts
oslo.policy.enforcer =
karbor = karbor.policy:get_enforcer
oslo.policy.policies =
# The sample policies will be ordered by entry point and then by list
# returned from that entry point. If more control is desired split out each
# list_rules method into a separate entry point rather than using the
# aggregate method.
karbor = karbor.policies:list_rules
wsgi_scripts =
karbor-wsgi = karbor.wsgi.wsgi:initialize_application
karbor.database.migration_backend =

View File

@ -58,6 +58,9 @@ commands = sphinx-build -a -E -d releasenotes/build/doctrees -b html releasenote
[testenv:genconfig]
commands = oslo-config-generator --config-file etc/oslo-config-generator/karbor.conf
[testenv:genpolicy]
commands = oslopolicy-sample-generator --config-file=etc/karbor-policy-generator.conf
[flake8]
show-source = True
ignore =