Add infrastructure for testing new RBAC policies
A new BasePolicyTest class (heavily borrowed from nova) provides machinery for verifying policies against a list of contexts for which an action should be authorized, and then again against a list of contexts for which the action should be unauthorized. The set of contexts map to the OpenStack RBAC personas. Cinder's context.authorize() function is updated to ensure the correct project_id is used when authenticating against a target object. A unit test utility function "attach_volume" is fixed to correctly use an elevated context when creating an attachment DB entry. Change-Id: Ic8d9d3608ad4eb2c3188d04d59605f3b5a768e30
This commit is contained in:
parent
7fb4f272cd
commit
81e0da35dc
@ -224,6 +224,19 @@ class RequestContext(context.RequestContext):
|
||||
# Turn object into dict so target.update can work
|
||||
target.update(
|
||||
target_obj.obj_to_primitive()['versioned_object.data'] or {})
|
||||
|
||||
# Ensure 'project_id' and 'user_id' attributes are captured.
|
||||
# Some objects (e.g. attachments) have a project_id attribute
|
||||
# that isn't present in the dict. The try/except wrappers avoid
|
||||
# lazy-load issues when the attribute doesn't exist.
|
||||
try:
|
||||
target['project_id'] = target_obj.project_id
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
target['user_id'] = target_obj.user_id
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
target.update(target_obj or {})
|
||||
|
||||
|
@ -41,7 +41,8 @@ def reset():
|
||||
_ENFORCER = None
|
||||
|
||||
|
||||
def init(use_conf=True):
|
||||
def init(use_conf=True,
|
||||
suppress_deprecation_warnings=False):
|
||||
"""Init an Enforcer class.
|
||||
|
||||
:param use_conf: Whether to load rules from config file.
|
||||
@ -53,6 +54,7 @@ def init(use_conf=True):
|
||||
CONF,
|
||||
use_conf=use_conf,
|
||||
fallback_to_json_file=False)
|
||||
_ENFORCER.suppress_deprecation_warnings = suppress_deprecation_warnings
|
||||
register_rules(_ENFORCER)
|
||||
_ENFORCER.load_rules()
|
||||
|
||||
|
@ -21,8 +21,13 @@ import os
|
||||
import warnings
|
||||
|
||||
import fixtures
|
||||
from oslo_config import cfg
|
||||
from oslo_policy import policy as oslo_policy
|
||||
from oslo_privsep import daemon as privsep_daemon
|
||||
|
||||
import cinder.policy
|
||||
|
||||
CONF = cfg.CONF
|
||||
_TRUE_VALUES = ('True', 'true', '1', 'yes')
|
||||
|
||||
|
||||
@ -158,3 +163,23 @@ class PrivsepNoHelperFixture(fixtures.Fixture):
|
||||
self.useFixture(fixtures.MonkeyPatch(
|
||||
'oslo_privsep.daemon.RootwrapClientChannel',
|
||||
UnHelperfulClientChannel))
|
||||
|
||||
|
||||
class PolicyFixture(fixtures.Fixture):
|
||||
"""Load the live policy for tests.
|
||||
|
||||
A base policy fixture that starts with the assumption that you'd
|
||||
like to load and enforce the shipped default policy in tests.
|
||||
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
cinder.policy.reset()
|
||||
# Suppress deprecation warnings for unit tests.
|
||||
cinder.policy.init(suppress_deprecation_warnings=True)
|
||||
self.addCleanup(cinder.policy.reset)
|
||||
|
||||
def set_rules(self, rules, overwrite=True):
|
||||
policy = cinder.policy._ENFORCER
|
||||
policy.set_rules(oslo_policy.Rules.from_dict(rules),
|
||||
overwrite=overwrite)
|
||||
|
190
cinder/tests/unit/policies/base.py
Normal file
190
cinder/tests/unit/policies/base.py
Normal file
@ -0,0 +1,190 @@
|
||||
# Copyright 2021 Red Hat, Inc.
|
||||
# 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_log import log as logging
|
||||
from oslo_utils.fixture import uuidsentinel as uuids
|
||||
|
||||
from cinder import context as cinder_context
|
||||
from cinder import exception
|
||||
from cinder.tests import fixtures
|
||||
from cinder.tests.unit import test
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# The list of users, with characterstics/persona implied by the name,
|
||||
# are declared statically for use as DDT data.
|
||||
all_users = [
|
||||
'legacy_admin',
|
||||
'legacy_owner',
|
||||
'system_admin',
|
||||
# NOTE: Xena does not support these system scoped personae. They need
|
||||
# to be tested in Yoga when support is added for system scope.
|
||||
# 'system_member',
|
||||
# 'system_reader',
|
||||
# 'system_foo',
|
||||
'project_admin',
|
||||
'project_member',
|
||||
'project_reader',
|
||||
'project_foo',
|
||||
'other_project_member',
|
||||
'other_project_reader',
|
||||
]
|
||||
|
||||
|
||||
class BasePolicyTest(test.TestCase):
|
||||
def setUp(self, enforce_scope, enforce_new_defaults, *args, **kwargs):
|
||||
super().setUp(*args, **kwargs)
|
||||
self.enforce_scope = enforce_scope
|
||||
self.enforce_new_defaults = enforce_new_defaults
|
||||
self.override_config('enforce_scope',
|
||||
enforce_scope, 'oslo_policy')
|
||||
self.override_config('enforce_new_defaults',
|
||||
enforce_new_defaults, 'oslo_policy')
|
||||
self.policy = self.useFixture(fixtures.PolicyFixture())
|
||||
|
||||
self.admin_project_id = uuids.admin_project_id
|
||||
self.project_id = uuids.project_id
|
||||
self.project_id_other = uuids.project_id_other
|
||||
|
||||
self.context_details = {
|
||||
'legacy_admin': dict(
|
||||
project_id=self.admin_project_id,
|
||||
roles=['admin', 'member', 'reader'],
|
||||
),
|
||||
'legacy_owner': dict(
|
||||
project_id=self.project_id,
|
||||
roles=[],
|
||||
),
|
||||
'system_admin': dict(
|
||||
roles=['admin', 'member', 'reader'],
|
||||
# NOTE: The system_admin in Xena is project scoped, and will
|
||||
# change in Yoga when support is added for system scope.
|
||||
project_id=self.admin_project_id,
|
||||
# system_scope='all',
|
||||
),
|
||||
'project_admin': dict(
|
||||
project_id=self.project_id,
|
||||
roles=['admin', 'member', 'reader'],
|
||||
),
|
||||
'project_member': dict(
|
||||
project_id=self.project_id,
|
||||
roles=['member', 'reader'],
|
||||
),
|
||||
'project_reader': dict(
|
||||
project_id=self.project_id,
|
||||
roles=['reader'],
|
||||
),
|
||||
'project_foo': dict(
|
||||
project_id=self.project_id,
|
||||
roles=['foo'],
|
||||
),
|
||||
'other_project_member': dict(
|
||||
project_id=self.project_id_other,
|
||||
roles=['member', 'reader'],
|
||||
),
|
||||
'other_project_reader': dict(
|
||||
project_id=self.project_id_other,
|
||||
roles=['reader'],
|
||||
),
|
||||
}
|
||||
|
||||
# These context objects are useful for subclasses to create test
|
||||
# resources (e.g. volumes). Subclasses may create additional
|
||||
# contexts as needed.
|
||||
self.project_admin_context = self.create_context('project_admin')
|
||||
self.project_member_context = self.create_context('project_member')
|
||||
|
||||
def is_authorized(self, user_id, authorized_users, unauthorized_users):
|
||||
if user_id in authorized_users:
|
||||
return True
|
||||
elif user_id in unauthorized_users:
|
||||
return False
|
||||
else:
|
||||
msg = ('"%s" must be either an authorized or unauthorized user.'
|
||||
% (user_id))
|
||||
raise exception.CinderException(message=msg)
|
||||
|
||||
def create_context(self, user_id):
|
||||
try:
|
||||
details = self.context_details[user_id]
|
||||
except KeyError:
|
||||
msg = ('No context details defined for user_id "%s".' % (user_id))
|
||||
raise exception.CinderException(message=msg)
|
||||
|
||||
return cinder_context.RequestContext(user_id=user_id, **details)
|
||||
|
||||
def common_policy_check(self, user_id, authorized_users,
|
||||
unauthorized_users, unauthorized_exceptions,
|
||||
rule_name, func, req, *args, **kwargs):
|
||||
|
||||
req.environ['cinder.context'] = self.create_context(user_id)
|
||||
fatal = kwargs.pop('fatal', True)
|
||||
|
||||
def ensure_raises(req, *args, **kwargs):
|
||||
try:
|
||||
func(req, *args, **kwargs)
|
||||
except exception.NotAuthorized as exc:
|
||||
# In case of multi-policy APIs, PolicyNotAuthorized can be
|
||||
# raised from either of the policy so checking the error
|
||||
# message, which includes the rule name, can mismatch. Tests
|
||||
# verifying the multi policy can pass rule_name as None to
|
||||
# skip the error message assert.
|
||||
if (isinstance(exc, exception.PolicyNotAuthorized) and
|
||||
rule_name is not None):
|
||||
self.assertEqual(
|
||||
"Policy doesn't allow %s to be performed." %
|
||||
rule_name, exc.args[0])
|
||||
except Exception as exc:
|
||||
self.assertIn(type(exc), unauthorized_exceptions)
|
||||
else:
|
||||
msg = ('"%s" was authorized for "%s" policy when it should '
|
||||
'be unauthorized.' % (user_id, rule_name))
|
||||
raise exception.CinderException(message=msg)
|
||||
|
||||
return None
|
||||
|
||||
if self.is_authorized(user_id, authorized_users, unauthorized_users):
|
||||
# Verify the context having allowed scope and roles pass
|
||||
# the policy check.
|
||||
LOG.info('Testing authorized "%s"', user_id) # noqa: ignore=C309
|
||||
response = func(req, *args, **kwargs)
|
||||
else:
|
||||
# Verify the context not having allowed scope or roles fail
|
||||
# the policy check.
|
||||
LOG.info('Testing unauthorized "%s"', user_id) # noqa: ignore=C309
|
||||
if not fatal:
|
||||
try:
|
||||
response = func(req, *args, **kwargs)
|
||||
# We need to ignore the PolicyNotAuthorized
|
||||
# exception here so that we can add the correct response
|
||||
# in unauthorize_response for the case of fatal=False.
|
||||
# This handle the case of multi policy checks where tests
|
||||
# are verifying the second policy via the response of
|
||||
# fatal-False and ignoring the response checks where the
|
||||
# first policy itself fail to pass (even test override the
|
||||
# first policy to allow for everyone but still, scope
|
||||
# checks can leads to PolicyNotAuthorized error).
|
||||
# For example: flavor extra specs policy for GET flavor
|
||||
# API. In that case, flavor extra spec policy is checked
|
||||
# after the GET flavor policy. So any context failing on
|
||||
# GET flavor will raise the PolicyNotAuthorized and for
|
||||
# that case we do not have any way to verify the flavor
|
||||
# extra specs so skip that context to check in test.
|
||||
except exception.PolicyNotAuthorized:
|
||||
pass
|
||||
else:
|
||||
response = ensure_raises(req, *args, **kwargs)
|
||||
|
||||
return response
|
@ -16,8 +16,11 @@
|
||||
from unittest import mock
|
||||
|
||||
import ddt
|
||||
from oslo_policy import policy as oslo_policy
|
||||
|
||||
from cinder import context
|
||||
from cinder.objects import base as objects_base
|
||||
from cinder import policy
|
||||
from cinder.tests.unit import test
|
||||
|
||||
|
||||
@ -129,3 +132,83 @@ class ContextTestCase(test.TestCase):
|
||||
'222',
|
||||
roles=roles)
|
||||
self.assertEqual(roles, ctxt.roles)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ContextAuthorizeTestCase(test.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(ContextAuthorizeTestCase, self).setUp()
|
||||
rules = [
|
||||
oslo_policy.RuleDefault("test:something",
|
||||
"project_id:%(project_id)s"),
|
||||
]
|
||||
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(user_id='me',
|
||||
project_id='my_project')
|
||||
self.addCleanup(policy.reset)
|
||||
|
||||
def _dict_target_obj(project_id):
|
||||
return {
|
||||
'user_id': 'me',
|
||||
'project_id': project_id,
|
||||
}
|
||||
|
||||
def _real_target_obj(project_id):
|
||||
target_obj = objects_base.CinderObject()
|
||||
target_obj.user_id = 'me'
|
||||
target_obj.project_id = project_id
|
||||
return target_obj
|
||||
|
||||
@ddt.data(
|
||||
{
|
||||
# PASS: target inherits 'my_project' from target_obj dict
|
||||
'target': None,
|
||||
'target_obj': _dict_target_obj('my_project'),
|
||||
'expected': True,
|
||||
},
|
||||
{
|
||||
# FAIL: target inherits 'other_project' from target_obj dict
|
||||
'target': None,
|
||||
'target_obj': _dict_target_obj('other_project'),
|
||||
'expected': False,
|
||||
},
|
||||
{
|
||||
# PASS: target inherits 'my_project' from target_obj object
|
||||
'target': None,
|
||||
'target_obj': _real_target_obj('my_project'),
|
||||
'expected': True,
|
||||
},
|
||||
{
|
||||
# FAIL: target inherits 'other_project' from target_obj object
|
||||
'target': None,
|
||||
'target_obj': _real_target_obj('other_project'),
|
||||
'expected': False,
|
||||
},
|
||||
{
|
||||
# PASS: target specifies 'my_project'
|
||||
'target': {'project_id': 'my_project'},
|
||||
'target_obj': None,
|
||||
'expected': True,
|
||||
},
|
||||
{
|
||||
# FAIL: target specifies 'other_project'
|
||||
'target': {'project_id': 'other_project'},
|
||||
'target_obj': None,
|
||||
'expected': False,
|
||||
},
|
||||
{
|
||||
# PASS: target inherits 'my_project' from the context
|
||||
'target': None,
|
||||
'target_obj': None,
|
||||
'expected': True,
|
||||
},
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_authorize(self, target, target_obj, expected):
|
||||
result = self.context.authorize("test:something",
|
||||
target, target_obj, fatal=False)
|
||||
self.assertEqual(result, expected)
|
||||
|
@ -115,9 +115,9 @@ def attach_volume(ctxt, volume_id, instance_uuid, attached_host,
|
||||
values['mountpoint'] = mountpoint
|
||||
values['attach_time'] = now
|
||||
|
||||
attachment = db.volume_attach(ctxt, values)
|
||||
attachment = db.volume_attach(ctxt.elevated(), values)
|
||||
volume, updated_values = db.volume_attached(
|
||||
ctxt, attachment['id'], instance_uuid,
|
||||
ctxt.elevated(), attachment['id'], instance_uuid,
|
||||
attached_host, mountpoint, mode)
|
||||
return volume
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user