Use policy for control the 'host' field

In before, we manually remove the 'host' field from container
if the request was not made by admin users. This patch allows
operators to define a policy to decide whether to expose
this field. This is more flexible.

In addition, the field removal logic is moved to 'format_container'
to ensure that the field was removed (by policy) in all HTTP
methods (instead of 'GET' method only).

The policy is of the form "container:get_one:<field_name>".
For example, if the field is 'host', the corresponding policy
would be "container:get_one:host". If there is no policy defined
for a field, this field is allowed. Otherwise, the visibility
of this field is decided by the defined policy. The policy
enforcement logic is extended to allow a field 'might_not_exist'.

Change-Id: I40a0bfb4c26b8b46218f36ae0da9043b0f40b10b
This commit is contained in:
Hongbin Lu 2018-04-15 19:50:23 +00:00
parent a7ae077e85
commit ccfc7c2418
6 changed files with 181 additions and 21 deletions

View File

@ -31,6 +31,7 @@ from zun.common import context as zun_context
from zun.common import exception from zun.common import exception
from zun.common.i18n import _ from zun.common.i18n import _
from zun.common import name_generator from zun.common import name_generator
from zun.common.policies import container as policies
from zun.common import policy from zun.common import policy
from zun.common import utils from zun.common import utils
import zun.conf import zun.conf
@ -66,9 +67,10 @@ class ContainerCollection(collection.Collection):
@staticmethod @staticmethod
def convert_with_links(rpc_containers, limit, url=None, def convert_with_links(rpc_containers, limit, url=None,
expand=False, **kwargs): expand=False, **kwargs):
context = pecan.request.context
collection = ContainerCollection() collection = ContainerCollection()
collection.containers = \ collection.containers = \
[view.format_container(url, p) for p in rpc_containers] [view.format_container(url, p, context) for p in rpc_containers]
collection.next = collection.get_next(limit, url=url, **kwargs) collection.next = collection.get_next(limit, url=url, **kwargs)
return collection return collection
@ -210,6 +212,8 @@ class ContainersController(base.Controller):
filters = {} filters = {}
for filter_key in container_allowed_filters: for filter_key in container_allowed_filters:
if filter_key in kwargs: if filter_key in kwargs:
policy_action = policies.CONTAINER % ('get_one:' + filter_key)
context.can(policy_action, might_not_exist=True)
filter_value = kwargs.pop(filter_key) filter_value = kwargs.pop(filter_key)
filters[filter_key] = filter_value filters[filter_key] = filter_value
marker_obj = None marker_obj = None
@ -228,9 +232,6 @@ class ContainersController(base.Controller):
sort_key, sort_key,
sort_dir, sort_dir,
filters=filters) filters=filters)
if not context.is_admin:
for container in containers:
del container.host
return ContainerCollection.convert_with_links(containers, limit, return ContainerCollection.convert_with_links(containers, limit,
url=resource_url, url=resource_url,
expand=expand, expand=expand,
@ -257,9 +258,8 @@ class ContainersController(base.Controller):
except exception.ContainerHostNotUp: except exception.ContainerHostNotUp:
raise exception.ServerNotUsable raise exception.ServerNotUsable
if not context.is_admin: return view.format_container(pecan.request.host_url, container,
del container.host context)
return view.format_container(pecan.request.host_url, container)
def _generate_name_for_container(self): def _generate_name_for_container(self):
"""Generate a random name like: zeta-22-container.""" """Generate a random name like: zeta-22-container."""
@ -366,7 +366,8 @@ class ContainersController(base.Controller):
pecan.response.location = link.build_url('containers', pecan.response.location = link.build_url('containers',
new_container.uuid) new_container.uuid)
pecan.response.status = 202 pecan.response.status = 202
return view.format_container(pecan.request.host_url, new_container) return view.format_container(pecan.request.host_url, new_container,
context)
def _set_default_resource_limit(self, container_dict): def _set_default_resource_limit(self, container_dict):
# NOTE(kiennt): Default disk size will be set later. # NOTE(kiennt): Default disk size will be set later.
@ -567,7 +568,8 @@ class ContainersController(base.Controller):
context = pecan.request.context context = pecan.request.context
compute_api = pecan.request.compute_api compute_api = pecan.request.compute_api
container = compute_api.container_update(context, container, patch) container = compute_api.container_update(context, container, patch)
return view.format_container(pecan.request.host_url, container) return view.format_container(pecan.request.host_url, container,
context)
@base.Controller.api_version("1.1", "1.13") @base.Controller.api_version("1.1", "1.13")
@pecan.expose('json') @pecan.expose('json')
@ -587,7 +589,8 @@ class ContainersController(base.Controller):
container.name = name container.name = name
context = pecan.request.context context = pecan.request.context
container.save(context) container.save(context)
return view.format_container(pecan.request.host_url, container) return view.format_container(pecan.request.host_url, container,
context)
@pecan.expose('json') @pecan.expose('json')
@exception.wrap_pecan_controller_exception @exception.wrap_pecan_controller_exception

View File

@ -14,6 +14,7 @@
import itertools import itertools
from zun.api.controllers import link from zun.api.controllers import link
from zun.common.policies import container as policies
_basic_keys = ( _basic_keys = (
'uuid', 'uuid',
@ -49,10 +50,14 @@ _basic_keys = (
) )
def format_container(url, container): def format_container(url, container, context):
def transform(key, value): def transform(key, value):
if key not in _basic_keys: if key not in _basic_keys:
return return
# strip the key if it is not allowed by policy
policy_action = policies.CONTAINER % ('get_one:%s' % key)
if not context.can(policy_action, fatal=False, might_not_exist=True):
return
if key == 'uuid': if key == 'uuid':
yield ('uuid', value) yield ('uuid', value)
yield ('links', [link.make_link( yield ('links', [link.make_link(

View File

@ -122,7 +122,7 @@ class RequestContext(context.RequestContext):
return context return context
def can(self, action, target=None, fatal=True): def can(self, action, target=None, fatal=True, might_not_exist=False):
"""Verifies that the given action is valid on the target in this context. """Verifies that the given action is valid on the target in this context.
:param action: string representing the action to be checked. :param action: string representing the action to be checked.
@ -133,6 +133,9 @@ class RequestContext(context.RequestContext):
{'project_id': self.project_id, 'user_id': self.user_id} {'project_id': self.project_id, 'user_id': self.user_id}
:param fatal: if False, will return False when an :param fatal: if False, will return False when an
exception.NotAuthorized occurs. exception.NotAuthorized occurs.
:param might_not_exist: If True the policy check is skipped (and the
function returns True) if the specified policy does not exist.
Defaults to false.
:raises zun.common.exception.NotAuthorized: if verification fails and :raises zun.common.exception.NotAuthorized: if verification fails and
fatal is True. fatal is True.
@ -145,7 +148,8 @@ class RequestContext(context.RequestContext):
'user_id': self.user_id} 'user_id': self.user_id}
try: try:
return policy.authorize(self, action, target) return policy.authorize(self, action, target,
might_not_exist=might_not_exist)
except exception.NotAuthorized: except exception.NotAuthorized:
if fatal: if fatal:
raise raise

View File

@ -72,6 +72,29 @@ rules = [
} }
] ]
), ),
policy.DocumentedRuleDefault(
name=CONTAINER % 'get_one:host',
check_str=base.RULE_ADMIN_API,
description='Retrieve the host field of containers.',
operations=[
{
'path': '/v1/containers/{container_ident}',
'method': 'GET'
},
{
'path': '/v1/containers',
'method': 'GET'
},
{
'path': '/v1/containers',
'method': 'POST'
},
{
'path': '/v1/containers/{container_ident}',
'method': 'PATCH'
}
]
),
policy.DocumentedRuleDefault( policy.DocumentedRuleDefault(
name=CONTAINER % 'get_one_all_projects', name=CONTAINER % 'get_one_all_projects',
check_str=base.RULE_ADMIN_API, check_str=base.RULE_ADMIN_API,

View File

@ -102,7 +102,8 @@ def enforce(context, rule=None, target=None,
do_raise=do_raise, exc=exc, *args, **kwargs) do_raise=do_raise, exc=exc, *args, **kwargs)
def authorize(context, action, target, do_raise=True, exc=None): def authorize(context, action, target, do_raise=True, exc=None,
might_not_exist=False):
"""Verifies that the action is valid on the target in this context. """Verifies that the action is valid on the target in this context.
:param context: zun context :param context: zun context
@ -115,10 +116,13 @@ def authorize(context, action, target, do_raise=True, exc=None):
:param do_raise: if True (the default), raises PolicyNotAuthorized; :param do_raise: if True (the default), raises PolicyNotAuthorized;
if False, returns False if False, returns False
:param exc: Class of the exception to raise if the check fails. :param exc: Class of the exception to raise if the check fails.
Any remaining arguments passed to :meth:`authorize` (both Any remaining arguments passed to :meth:`authorize` (both
positional and keyword arguments) will be passed to positional and keyword arguments) will be passed to
the exception class. If not specified, the exception class. If not specified,
:class:`PolicyNotAuthorized` will be used. :class:`PolicyNotAuthorized` will be used.
:param might_not_exist: If True the policy check is skipped (and the
function returns True) if the specified policy does not exist.
Defaults to false.
:raises zun.common.exception.PolicyNotAuthorized: if verification fails :raises zun.common.exception.PolicyNotAuthorized: if verification fails
and do_raise is True. Or if 'exc' is specified it will raise an and do_raise is True. Or if 'exc' is specified it will raise an
@ -131,6 +135,8 @@ def authorize(context, action, target, do_raise=True, exc=None):
credentials = context.to_policy_values() credentials = context.to_policy_values()
if not exc: if not exc:
exc = exception.PolicyNotAuthorized exc = exception.PolicyNotAuthorized
if might_not_exist and not (_ENFORCER.rules and action in _ENFORCER.rules):
return True
try: try:
result = _ENFORCER.enforce(action, target, credentials, result = _ENFORCER.enforce(action, target, credentials,
do_raise=do_raise, exc=exc, action=action) do_raise=do_raise, exc=exc, action=action)

View File

@ -166,6 +166,29 @@ class TestContainerController(api_base.FunctionalTest):
content_type='application/json') content_type='application/json')
self.assertEqual(202, response.status_int) self.assertEqual(202, response.status_int)
self.assertNotIn('host', response.json)
self.assertTrue(mock_container_create.called)
self.assertTrue(mock_container_create.call_args[1]['run'] is False)
mock_neutron_get_network.assert_called_once()
@patch('zun.common.context.RequestContext.can')
@patch('zun.network.neutron.NeutronAPI.get_available_network')
@patch('zun.compute.api.API.container_create')
@patch('zun.compute.api.API.image_search')
def test_create_container_by_admin(
self, mock_search, mock_container_create, mock_neutron_get_network,
mock_can):
mock_container_create.side_effect = lambda x, y, **z: y
mock_can.return_value = True
params = ('{"name": "MyDocker", "image": "ubuntu",'
'"command": "env", "memory": "512",'
'"environment": {"key1": "val1", "key2": "val2"}}')
response = self.post('/v1/containers/',
params=params,
content_type='application/json')
self.assertEqual(202, response.status_int)
self.assertIn('host', response.json)
self.assertTrue(mock_container_create.called) self.assertTrue(mock_container_create.called)
self.assertTrue(mock_container_create.call_args[1]['run'] is False) self.assertTrue(mock_container_create.call_args[1]['run'] is False)
mock_neutron_get_network.assert_called_once() mock_neutron_get_network.assert_called_once()
@ -269,6 +292,7 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual(20, c.get('disk')) self.assertEqual(20, c.get('disk'))
self.assertEqual({"Name": "no", "MaximumRetryCount": "0"}, self.assertEqual({"Name": "no", "MaximumRetryCount": "0"},
c.get('restart_policy')) c.get('restart_policy'))
self.assertNotIn('host', c)
requested_networks = \ requested_networks = \
mock_container_create.call_args[1]['requested_networks'] mock_container_create.call_args[1]['requested_networks']
self.assertEqual(1, len(requested_networks)) self.assertEqual(1, len(requested_networks))
@ -328,6 +352,7 @@ class TestContainerController(api_base.FunctionalTest):
self.assertIsNone(c.get('runtime')) self.assertIsNone(c.get('runtime'))
self.assertIsNone(c.get('hostname')) self.assertIsNone(c.get('hostname'))
self.assertEqual({}, c.get('restart_policy')) self.assertEqual({}, c.get('restart_policy'))
self.assertNotIn('host', c)
mock_neutron_get_network.assert_called_once() mock_neutron_get_network.assert_called_once()
requested_networks = \ requested_networks = \
mock_container_create.call_args[1]['requested_networks'] mock_container_create.call_args[1]['requested_networks']
@ -365,6 +390,7 @@ class TestContainerController(api_base.FunctionalTest):
# [1] https://bugs.launchpad.net/zun/+bug/1746401 # [1] https://bugs.launchpad.net/zun/+bug/1746401
# self.assertEqual(10, c.get('disk')) # self.assertEqual(10, c.get('disk'))
self.assertEqual({}, c.get('environment')) self.assertEqual({}, c.get('environment'))
self.assertNotIn('host', c)
mock_neutron_get_network.assert_called_once() mock_neutron_get_network.assert_called_once()
extra_spec = \ extra_spec = \
mock_container_create.call_args[1]['extra_spec'] mock_container_create.call_args[1]['extra_spec']
@ -400,6 +426,7 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual('512', c.get('memory')) self.assertEqual('512', c.get('memory'))
self.assertEqual({"Name": "no", "MaximumRetryCount": "0"}, self.assertEqual({"Name": "no", "MaximumRetryCount": "0"},
c.get('restart_policy')) c.get('restart_policy'))
self.assertNotIn('host', c)
mock_neutron_get_network.assert_called_once() mock_neutron_get_network.assert_called_once()
requested_networks = \ requested_networks = \
mock_container_create.call_args[1]['requested_networks'] mock_container_create.call_args[1]['requested_networks']
@ -436,6 +463,7 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual('512', c.get('memory')) self.assertEqual('512', c.get('memory'))
self.assertEqual({"Name": "no", "MaximumRetryCount": "0"}, self.assertEqual({"Name": "no", "MaximumRetryCount": "0"},
c.get('restart_policy')) c.get('restart_policy'))
self.assertNotIn('host', c)
mock_neutron_get_network.assert_called_once() mock_neutron_get_network.assert_called_once()
requested_networks = \ requested_networks = \
mock_container_create.call_args[1]['requested_networks'] mock_container_create.call_args[1]['requested_networks']
@ -472,6 +500,7 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual('512', c.get('memory')) self.assertEqual('512', c.get('memory'))
self.assertEqual({"Name": "unless-stopped", "MaximumRetryCount": "0"}, self.assertEqual({"Name": "unless-stopped", "MaximumRetryCount": "0"},
c.get('restart_policy')) c.get('restart_policy'))
self.assertNotIn('host', c)
mock_neutron_get_network.assert_called_once() mock_neutron_get_network.assert_called_once()
requested_networks = \ requested_networks = \
mock_container_create.call_args[1]['requested_networks'] mock_container_create.call_args[1]['requested_networks']
@ -516,6 +545,7 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual('512', c.get('memory')) self.assertEqual('512', c.get('memory'))
self.assertEqual({"key1": "val1", "key2": "val2"}, self.assertEqual({"key1": "val1", "key2": "val2"},
c.get('environment')) c.get('environment'))
self.assertNotIn('host', c)
requested_networks = \ requested_networks = \
mock_container_create.call_args[1]['requested_networks'] mock_container_create.call_args[1]['requested_networks']
self.assertEqual(1, len(requested_networks)) self.assertEqual(1, len(requested_networks))
@ -638,6 +668,7 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual('env', c.get('command')) self.assertEqual('env', c.get('command'))
self.assertEqual('Creating', c.get('status')) self.assertEqual('Creating', c.get('status'))
self.assertEqual('512', c.get('memory')) self.assertEqual('512', c.get('memory'))
self.assertIn('host', c)
requested_networks = \ requested_networks = \
mock_container_create.call_args[1]['requested_networks'] mock_container_create.call_args[1]['requested_networks']
self.assertEqual(1, len(requested_networks)) self.assertEqual(1, len(requested_networks))
@ -701,6 +732,29 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual(1, len(actual_containers)) self.assertEqual(1, len(actual_containers))
self.assertEqual(test_container['uuid'], self.assertEqual(test_container['uuid'],
actual_containers[0].get('uuid')) actual_containers[0].get('uuid'))
self.assertNotIn('host', actual_containers[0])
@patch('zun.common.context.RequestContext.can')
@patch('zun.objects.Container.list')
def test_get_all_containers_by_admin(self, mock_container_list, mock_can):
mock_can.return_value = True
test_container = utils.get_test_container()
containers = [objects.Container(self.context, **test_container)]
mock_container_list.return_value = containers
response = self.get('/v1/containers/')
mock_container_list.assert_called_once_with(mock.ANY,
1000, None, 'id', 'asc',
filters={})
context = mock_container_list.call_args[0][0]
self.assertIs(False, context.all_projects)
self.assertEqual(200, response.status_int)
actual_containers = response.json['containers']
self.assertEqual(1, len(actual_containers))
self.assertEqual(test_container['uuid'],
actual_containers[0].get('uuid'))
self.assertIn('host', actual_containers[0])
@patch('zun.common.policy.enforce') @patch('zun.common.policy.enforce')
@patch('zun.objects.Container.list') @patch('zun.objects.Container.list')
@ -776,6 +830,22 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual(test_container['uuid'], self.assertEqual(test_container['uuid'],
actual_containers[0].get('uuid')) actual_containers[0].get('uuid'))
@patch('zun.objects.Container.list')
def test_get_all_containers_with_filter_disallow(
self, mock_container_list):
test_container = utils.get_test_container()
containers = [objects.Container(self.context, **test_container)]
mock_container_list.return_value = containers
response = self.get('/v1/containers/?host=fake-name',
expect_errors=True)
self.assertEqual(403, response.status_int)
self.assertEqual('application/json', response.content_type)
rule = 'container:get_one:host'
self.assertEqual(
"Policy doesn't allow %s to be performed." % rule,
response.json['errors'][0]['detail'])
@patch('zun.objects.Container.list') @patch('zun.objects.Container.list')
def test_get_all_containers_with_unknown_parameter( def test_get_all_containers_with_unknown_parameter(
self, mock_container_list): self, mock_container_list):
@ -807,12 +877,10 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual(test_container['uuid'], self.assertEqual(test_container['uuid'],
actual_containers[0].get('uuid')) actual_containers[0].get('uuid'))
@patch('zun.common.policy.enforce')
@patch('zun.compute.api.API.container_show') @patch('zun.compute.api.API.container_show')
@patch('zun.objects.Container.get_by_uuid') @patch('zun.objects.Container.get_by_uuid')
def test_get_one_by_uuid(self, mock_container_get_by_uuid, def test_get_one_by_uuid(self, mock_container_get_by_uuid,
mock_container_show, mock_policy): mock_container_show):
mock_policy.return_value = True
test_container = utils.get_test_container() test_container = utils.get_test_container()
test_container_obj = objects.Container(self.context, **test_container) test_container_obj = objects.Container(self.context, **test_container)
mock_container_get_by_uuid.return_value = test_container_obj mock_container_get_by_uuid.return_value = test_container_obj
@ -828,6 +896,30 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual(200, response.status_int) self.assertEqual(200, response.status_int)
self.assertEqual(test_container['uuid'], self.assertEqual(test_container['uuid'],
response.json['uuid']) response.json['uuid'])
self.assertNotIn('host', response.json)
@patch('zun.common.context.RequestContext.can')
@patch('zun.compute.api.API.container_show')
@patch('zun.objects.Container.get_by_uuid')
def test_get_one_by_admin(self, mock_container_get_by_uuid,
mock_container_show, mock_can):
mock_can.return_value = True
test_container = utils.get_test_container()
test_container_obj = objects.Container(self.context, **test_container)
mock_container_get_by_uuid.return_value = test_container_obj
mock_container_show.return_value = test_container_obj
response = self.get('/v1/containers/%s/' % test_container['uuid'])
mock_container_get_by_uuid.assert_called_once_with(
mock.ANY,
test_container['uuid'])
context = mock_container_get_by_uuid.call_args[0][0]
self.assertIs(False, context.all_projects)
self.assertEqual(200, response.status_int)
self.assertEqual(test_container['uuid'],
response.json['uuid'])
self.assertIn('host', response.json)
@patch('zun.common.policy.enforce') @patch('zun.common.policy.enforce')
@patch('zun.compute.api.API.container_show') @patch('zun.compute.api.API.container_show')
@ -874,6 +966,33 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual(200, response.status_int) self.assertEqual(200, response.status_int)
self.assertTrue(mock_update.called) self.assertTrue(mock_update.called)
self.assertNotIn('host', response.json)
@patch('zun.common.context.RequestContext.can')
@patch('zun.objects.ComputeNode.get_by_name')
@patch('zun.compute.api.API.container_update')
@patch('zun.objects.Container.get_by_uuid')
def test_patch_by_admin(self, mock_container_get_by_uuid, mock_update,
mock_computenode, mock_can):
mock_can.return_value = True
test_container = utils.get_test_container()
test_container_obj = objects.Container(self.context, **test_container)
mock_container_get_by_uuid.return_value = test_container_obj
mock_update.return_value = test_container_obj
test_host = utils.get_test_compute_node()
numa = objects.numa.NUMATopology._from_dict(test_host['numa_topology'])
test_host['numa_topology'] = numa
test_host_obj = objects.ComputeNode(self.context, **test_host)
mock_computenode.return_value = test_host_obj
params = {'cpu': 1}
container_uuid = test_container.get('uuid')
response = self.patch_json(
'/containers/%s/' % container_uuid,
params=params)
self.assertEqual(200, response.status_int)
self.assertTrue(mock_update.called)
self.assertIn('host', response.json)
def _action_test(self, test_container_obj, action, ident_field, def _action_test(self, test_container_obj, action, ident_field,
mock_container_action, status_code, query_param=''): mock_container_action, status_code, query_param=''):