diff --git a/doc/source/policies.rst b/doc/source/policies.rst index 55cd2b9f18..a676fa9897 100644 --- a/doc/source/policies.rst +++ b/doc/source/policies.rst @@ -17,41 +17,41 @@ Policies ======== -Glance's public API calls may be restricted to certain sets of users using a +Glance's public API calls may be restricted to certain sets of users using a policy configuration file. This document explains exactly how policies are -configured and what they apply to. +configured and what they apply to. -A policy is composed of a set of rules that are used by the policy "Brain" in +A policy is composed of a set of rules that are used by the policy "Brain" in determining if a particular action may be performed by the authorized tenant. Constructing a Policy Configuration File ---------------------------------------- -A policy configuration file is a simply JSON object that contain sets of +A policy configuration file is a simply JSON object that contain sets of rules. Each top-level key is the name of a rule. Each rule is a string that describes an action that may be performed in the Glance API. The actions that may have a rule enforced on them are: * ``get_images`` - List available image entities - + * ``GET /v1/images`` * ``GET /v1/images/detail`` * ``GET /v2/images`` * ``get_image`` - Retrieve a specific image entity - + * ``HEAD /v1/images/`` * ``GET /v1/images/`` * ``GET /v2/images/`` * ``download_image`` - Download binary image data - + * ``GET /v1/images/`` * ``GET /v2/images//file`` * ``add_image`` - Create an image entity - + * ``POST /v1/images`` * ``POST /v2/images`` @@ -72,6 +72,27 @@ The actions that may have a rule enforced on them are: * ``DELETE /v1/images/`` * ``DELETE /v2/images/`` +* ``add_member`` - Add a membership to the member repo of an image + + * ``POST /v2/images//members`` + +* ``get_members`` - List the members of an image + + * ``GET /v1/images//members`` + * ``GET /v2/images//members`` + +* ``delete_member`` - Delete a membership of an image + + * ``DELETE /v1/images//members/`` + * ``DELETE /v2/images//members/`` + +* ``modify_member`` - Create or update the membership of an image + + * ``PUT /v1/images//members/`` + * ``PUT /v1/images//members`` + * ``POST /v2/images//members`` + * ``PUT /v2/images//members/`` + * ``manage_image_cache`` - Allowed to use the image cache management API diff --git a/glance/api/policy.py b/glance/api/policy.py index 9b3b05cc34..b4352725c3 100644 --- a/glance/api/policy.py +++ b/glance/api/policy.py @@ -211,6 +211,10 @@ class ImageProxy(glance.domain.proxy.Image): self.policy.enforce(self.context, 'download_image', {}) return self.image.get_data(*args, **kwargs) + def get_member_repo(self, **kwargs): + member_repo = self.image.get_member_repo(**kwargs) + return ImageMemberRepoProxy(member_repo, self.context, self.policy) + class ImageFactoryProxy(glance.domain.proxy.ImageFactory): @@ -227,3 +231,40 @@ class ImageFactoryProxy(glance.domain.proxy.ImageFactory): if kwargs.get('visibility') == 'public': self.policy.enforce(self.context, 'publicize_image', {}) return super(ImageFactoryProxy, self).new_image(**kwargs) + + +class ImageMemberFactoryProxy(glance.domain.proxy.ImageMembershipFactory): + + def __init__(self, member_factory, context, policy): + super(ImageMemberFactoryProxy, self).__init__( + member_factory, + image_proxy_class=ImageProxy, + image_proxy_kwargs={'context': context, 'policy': policy}) + + +class ImageMemberRepoProxy(glance.domain.proxy.Repo): + + def __init__(self, member_repo, context, policy): + self.member_repo = member_repo + self.context = context + self.policy = policy + + def add(self, member): + self.policy.enforce(self.context, 'add_member', {}) + return self.member_repo.add(member) + + def get(self, member_id): + self.policy.enforce(self.context, 'get_member', {}) + return self.member_repo.get(member_id) + + def save(self, member): + self.policy.enforce(self.context, 'modify_member', {}) + return self.member_repo.save(member) + + def list(self, *args, **kwargs): + self.policy.enforce(self.context, 'get_members', {}) + return self.member_repo.list(*args, **kwargs) + + def remove(self, member): + self.policy.enforce(self.context, 'delete_member', {}) + return self.member_repo.remove(member) diff --git a/glance/api/v1/members.py b/glance/api/v1/members.py index f7e7aaa445..0af9d08977 100644 --- a/glance/api/v1/members.py +++ b/glance/api/v1/members.py @@ -17,6 +17,7 @@ import webob.exc +from glance.api import policy from glance.api.v1 import controller from glance.common import exception from glance.common import utils @@ -29,10 +30,20 @@ LOG = logging.getLogger(__name__) class Controller(controller.BaseController): + def __init__(self): + self.policy = policy.Enforcer() + def _check_can_access_image_members(self, context): if context.owner is None and not context.is_admin: raise webob.exc.HTTPUnauthorized(_("No authenticated user")) + def _enforce(self, req, action): + """Authorize an action against our policies""" + try: + self.policy.enforce(req.context, action, {}) + except exception.Forbidden: + raise webob.exc.HTTPForbidden() + def index(self, req, image_id): """ Return a list of dictionaries indicating the members of the @@ -47,6 +58,8 @@ class Controller(controller.BaseController): 'can_share': , ...}, ... ]} """ + self._enforce(req, 'get_members') + try: members = registry.get_image_members(req.context, image_id) except exception.NotFound: @@ -65,6 +78,7 @@ class Controller(controller.BaseController): Removes a membership from the image. """ self._check_can_access_image_members(req.context) + self._enforce(req, 'delete_member') try: registry.delete_member(req.context, image_id, id) @@ -99,6 +113,7 @@ class Controller(controller.BaseController): remain unchanged and new memberships default to False. """ self._check_can_access_image_members(req.context) + self._enforce(req, 'modify_member') # Figure out can_share can_share = None @@ -134,6 +149,7 @@ class Controller(controller.BaseController): ]} """ self._check_can_access_image_members(req.context) + self._enforce(req, 'modify_member') try: registry.replace_members(req.context, image_id, body) diff --git a/glance/gateway.py b/glance/gateway.py index f42a26cc16..4bebb6fa37 100644 --- a/glance/gateway.py +++ b/glance/gateway.py @@ -44,8 +44,10 @@ class Gateway(object): def get_image_member_factory(self, context): image_factory = glance.domain.ImageMemberFactory() + policy_member_factory = policy.ImageMemberFactoryProxy( + image_factory, context, self.policy) authorized_image_factory = authorization.ImageMemberFactoryProxy( - image_factory, context) + policy_member_factory, context) return authorized_image_factory def get_repo(self, context): diff --git a/glance/tests/unit/test_policy.py b/glance/tests/unit/test_policy.py index 8cfb818677..25f6abe1bd 100644 --- a/glance/tests/unit/test_policy.py +++ b/glance/tests/unit/test_policy.py @@ -60,6 +60,23 @@ class ImageFactoryStub(object): return 'new_image' +class MemberRepoStub(object): + def add(self, *args, **kwargs): + return 'member_repo_add' + + def get(self, *args, **kwargs): + return 'member_repo_get' + + def save(self, *args, **kwargs): + return 'member_repo_save' + + def list(self, *args, **kwargs): + return 'member_repo_list' + + def remove(self, *args, **kwargs): + return 'member_repo_remove' + + class TestPolicyEnforcer(base.IsolatedUnitTest): def test_policy_file_default_rules_default_location(self): enforcer = glance.api.policy.Enforcer() @@ -260,6 +277,69 @@ class TestImagePolicy(test_utils.BaseTestCase): self.assertRaises(exception.Forbidden, image.get_data) +class TestMemberPolicy(test_utils.BaseTestCase): + def setUp(self): + self.policy = unit_test_utils.FakePolicyEnforcer() + self.member_repo = glance.api.policy.ImageMemberRepoProxy( + MemberRepoStub(), {}, self.policy) + super(TestMemberPolicy, self).setUp() + + def test_add_member_not_allowed(self): + rules = {'add_member': False} + self.policy.set_rules(rules) + self.assertRaises(exception.Forbidden, self.member_repo.add, '') + + def test_add_member_allowed(self): + rules = {'add_member': True} + self.policy.set_rules(rules) + output = self.member_repo.add('') + self.assertEqual(output, 'member_repo_add') + + def test_get_member_not_allowed(self): + rules = {'get_member': False} + self.policy.set_rules(rules) + self.assertRaises(exception.Forbidden, self.member_repo.get, '') + + def test_get_member_allowed(self): + rules = {'get_member': True} + self.policy.set_rules(rules) + output = self.member_repo.get('') + self.assertEqual(output, 'member_repo_get') + + def test_modify_member_not_allowed(self): + rules = {'modify_member': False} + self.policy.set_rules(rules) + self.assertRaises(exception.Forbidden, self.member_repo.save, '') + + def test_modify_member_allowed(self): + rules = {'modify_member': True} + self.policy.set_rules(rules) + output = self.member_repo.save('') + self.assertEqual(output, 'member_repo_save') + + def test_get_members_not_allowed(self): + rules = {'get_members': False} + self.policy.set_rules(rules) + self.assertRaises(exception.Forbidden, self.member_repo.list, '') + + def test_get_members_allowed(self): + rules = {'get_members': True} + self.policy.set_rules(rules) + output = self.member_repo.list('') + self.assertEqual(output, 'member_repo_list') + + def test_delete_member_not_allowed(self): + rules = {'delete_member': False} + self.policy.set_rules(rules) + self.assertRaises(exception.Forbidden, self.member_repo.remove, '') + + def test_delete_member_allowed(self): + rules = {'delete_member': True} + self.policy.set_rules(rules) + output = self.member_repo.remove('') + self.assertEqual(output, 'member_repo_remove') + + class TestContextPolicyEnforcer(base.IsolatedUnitTest): def _do_test_policy_influence_context_admin(self, policy_admin_role, diff --git a/glance/tests/unit/v1/test_api.py b/glance/tests/unit/v1/test_api.py index 2e5152d970..7d11b28627 100644 --- a/glance/tests/unit/v1/test_api.py +++ b/glance/tests/unit/v1/test_api.py @@ -1497,6 +1497,30 @@ class TestGlanceAPI(base.IsolatedUnitTest): num_members = len(memb_list['members']) self.assertEquals(num_members, 0) + def test_get_image_members_allowed_by_policy(self): + rules = {"get_members": '@'} + self.set_policy_rules(rules) + + req = webob.Request.blank('/images/%s/members' % UUID2) + req.method = 'GET' + + res = req.get_response(self.api) + self.assertEquals(res.status_int, 200) + + memb_list = json.loads(res.body) + num_members = len(memb_list['members']) + self.assertEquals(num_members, 0) + + def test_get_image_members_forbidden_by_policy(self): + rules = {"get_members": '!'} + self.set_policy_rules(rules) + + req = webob.Request.blank('/images/%s/members' % UUID2) + req.method = 'GET' + + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPForbidden.code) + def test_get_image_members_not_existing(self): """ Tests proper exception is raised if attempt to get members of @@ -1602,6 +1626,36 @@ class TestGlanceAPI(base.IsolatedUnitTest): res = req.get_response(self.api) self.assertEquals(res.status_int, 204) + def test_replace_members_forbidden_by_policy(self): + rules = {"modify_member": '!'} + self.set_policy_rules(rules) + self.api = test_utils.FakeAuthMiddleware(router.API(self.mapper), + is_admin=True) + fixture = [{'member_id': 'pattieblack', 'can_share': 'false'}] + + req = webob.Request.blank('/images/%s/members' % UUID1) + req.method = 'PUT' + req.content_type = 'application/json' + req.body = json.dumps(dict(memberships=fixture)) + + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPForbidden.code) + + def test_replace_members_allowed_by_policy(self): + rules = {"modify_member": '@'} + self.set_policy_rules(rules) + self.api = test_utils.FakeAuthMiddleware(router.API(self.mapper), + is_admin=True) + fixture = [{'member_id': 'pattieblack', 'can_share': 'false'}] + + req = webob.Request.blank('/images/%s/members' % UUID1) + req.method = 'PUT' + req.content_type = 'application/json' + req.body = json.dumps(dict(memberships=fixture)) + + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPNoContent.code) + def test_add_member(self): """ Tests adding image members raises right exception @@ -1655,6 +1709,28 @@ class TestGlanceAPI(base.IsolatedUnitTest): res = req.get_response(self.api) self.assertEquals(res.status_int, 204) + def test_add_member_forbidden_by_policy(self): + rules = {"modify_member": '!'} + self.set_policy_rules(rules) + self.api = test_utils.FakeAuthMiddleware(router.API(self.mapper), + is_admin=True) + req = webob.Request.blank('/images/%s/members/pattieblack' % UUID1) + req.method = 'PUT' + + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPForbidden.code) + + def test_add_member_allowed_by_policy(self): + rules = {"modify_member": '@'} + self.set_policy_rules(rules) + self.api = test_utils.FakeAuthMiddleware(router.API(self.mapper), + is_admin=True) + req = webob.Request.blank('/images/%s/members/pattieblack' % UUID1) + req.method = 'PUT' + + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPNoContent.code) + def test_delete_member(self): """ Tests deleting image members raises right exception @@ -1718,6 +1794,32 @@ class TestGlanceAPI(base.IsolatedUnitTest): self.assertEquals(res.status_int, 404) self.assertTrue('Forbidden' in res.body) + def test_delete_member_allowed_by_policy(self): + rules = {"delete_member": '@', "modify_member": '@'} + self.set_policy_rules(rules) + self.api = test_utils.FakeAuthMiddleware(router.API(self.mapper), + is_admin=True) + req = webob.Request.blank('/images/%s/members/pattieblack' % UUID2) + req.method = 'PUT' + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPNoContent.code) + req.method = 'DELETE' + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPNoContent.code) + + def test_delete_member_forbidden_by_policy(self): + rules = {"delete_member": '!', "modify_member": '@'} + self.set_policy_rules(rules) + self.api = test_utils.FakeAuthMiddleware(router.API(self.mapper), + is_admin=True) + req = webob.Request.blank('/images/%s/members/pattieblack' % UUID2) + req.method = 'PUT' + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPNoContent.code) + req.method = 'DELETE' + res = req.get_response(self.api) + self.assertEquals(res.status_int, webob.exc.HTTPForbidden.code) + class TestImageSerializer(base.IsolatedUnitTest): def setUp(self): diff --git a/glance/tests/unit/v2/test_image_members_resource.py b/glance/tests/unit/v2/test_image_members_resource.py index ffd9544b02..8b9c4ea726 100644 --- a/glance/tests/unit/v2/test_image_members_resource.py +++ b/glance/tests/unit/v2/test_image_members_resource.py @@ -173,6 +173,20 @@ class TestImageMembersController(test_utils.BaseTestCase): expected = set([TENANT1]) self.assertEqual(actual, expected) + def test_index_allowed_by_get_members_policy(self): + rules = {"get_members": True} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request() + output = self.controller.index(request, UUID2) + self.assertEqual(1, len(output['members'])) + + def test_index_forbidden_by_get_members_policy(self): + rules = {"get_members": False} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request() + self.assertRaises(webob.exc.HTTPForbidden, self.controller.index, + request, image_id=UUID2) + def test_create(self): request = unit_test_utils.get_fake_request() image_id = UUID2 @@ -182,6 +196,22 @@ class TestImageMembersController(test_utils.BaseTestCase): self.assertEqual(UUID2, output.image_id) self.assertEqual(TENANT3, output.member_id) + def test_create_allowed_by_add_policy(self): + rules = {"add_member": True} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request() + output = self.controller.create(request, image_id=UUID2, + member_id=TENANT3) + self.assertEqual(UUID2, output.image_id) + self.assertEqual(TENANT3, output.member_id) + + def test_create_forbidden_by_add_policy(self): + rules = {"add_member": False} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request() + self.assertRaises(webob.exc.HTTPForbidden, self.controller.create, + request, image_id=UUID2, member_id=TENANT3) + def test_update_done_by_member(self): request = unit_test_utils.get_fake_request(tenant=TENANT4) image_id = UUID2 @@ -193,6 +223,25 @@ class TestImageMembersController(test_utils.BaseTestCase): self.assertEqual(TENANT4, output.member_id) self.assertEqual('accepted', output.status) + def test_update_done_by_member_forbidden_by_policy(self): + rules = {"modify_member": False} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request(tenant=TENANT4) + self.assertRaises(webob.exc.HTTPForbidden, self.controller.update, + request, image_id=UUID2, member_id=TENANT4, + status='accepted') + + def test_update_done_by_member_allowed_by_policy(self): + rules = {"modify_member": True} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request(tenant=TENANT4) + output = self.controller.update(request, image_id=UUID2, + member_id=TENANT4, + status='accepted') + self.assertEqual(UUID2, output.image_id) + self.assertEqual(TENANT4, output.member_id) + self.assertEqual('accepted', output.status) + def test_update_done_by_owner(self): request = unit_test_utils.get_fake_request(tenant=TENANT1) image_id = UUID2 @@ -246,6 +295,30 @@ class TestImageMembersController(test_utils.BaseTestCase): expected = set([TENANT4]) self.assertEqual(actual, expected) + def test_delete_allowed_by_policies(self): + rules = {"get_member": True, "delete_member": True} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request(tenant=TENANT1) + output = self.controller.delete(request, image_id=UUID2, + member_id=TENANT4) + request = unit_test_utils.get_fake_request() + output = self.controller.index(request, UUID2) + self.assertEqual(0, len(output['members'])) + + def test_delete_forbidden_by_get_member_policy(self): + rules = {"get_member": False} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request(tenant=TENANT1) + self.assertRaises(webob.exc.HTTPForbidden, self.controller.delete, + request, UUID2, TENANT4) + + def test_delete_forbidden_by_delete_member_policy(self): + rules = {"delete_member": False} + self.policy.set_rules(rules) + request = unit_test_utils.get_fake_request(tenant=TENANT1) + self.assertRaises(webob.exc.HTTPForbidden, self.controller.delete, + request, UUID2, TENANT4) + def test_delete_private_image(self): request = unit_test_utils.get_fake_request() self.assertRaises(webob.exc.HTTPForbidden, self.controller.delete,