diff --git a/doc/source/images_src/image_status_transition.dot b/doc/source/images_src/image_status_transition.dot
index 2ffd0d3328..353fae5e92 100644
--- a/doc/source/images_src/image_status_transition.dot
+++ b/doc/source/images_src/image_status_transition.dot
@@ -40,6 +40,10 @@ digraph {
   "active" -> "queued" [label="remove location*"];
   "active" -> "pending_delete" [label="delayed delete"];
   "active" -> "deleted" [label="delete"];
+  "active" -> "deactivated" [label="deactivate"];
+
+  "deactivated" -> "active" [label="reactivate"];
+  "deactivated" -> "deleted" [label="delete"];
 
   "killed" -> "deleted" [label="delete"];
 
diff --git a/doc/source/images_src/image_status_transition.png b/doc/source/images_src/image_status_transition.png
new file mode 100644
index 0000000000..a0e4e81ad4
Binary files /dev/null and b/doc/source/images_src/image_status_transition.png differ
diff --git a/etc/policy.json b/etc/policy.json
index 511fe8ec35..4bbc8b46c6 100644
--- a/etc/policy.json
+++ b/etc/policy.json
@@ -30,6 +30,9 @@
     "add_task": "",
     "modify_task": "",
 
+    "deactivate": "",
+    "reactivate": "",
+
     "get_metadef_namespace": "",
     "get_metadef_namespaces":"",
     "modify_metadef_namespace":"",
diff --git a/glance/api/middleware/cache.py b/glance/api/middleware/cache.py
index 956661d713..7a8085be9a 100644
--- a/glance/api/middleware/cache.py
+++ b/glance/api/middleware/cache.py
@@ -154,6 +154,11 @@ class CacheFilter(wsgi.Middleware):
             return None
         method = getattr(self, '_get_%s_image_metadata' % version)
         image_metadata = method(request, image_id)
+
+        # Deactivated images shall not be served from cache
+        if image_metadata['status'] == 'deactivated':
+            return None
+
         try:
             self._enforce(request, 'download_image', target=image_metadata)
         except exception.Forbidden:
diff --git a/glance/api/policy.py b/glance/api/policy.py
index 06f06e4d81..0cd3d56de3 100755
--- a/glance/api/policy.py
+++ b/glance/api/policy.py
@@ -165,6 +165,20 @@ class ImageProxy(glance.domain.proxy.Image):
         self.policy.enforce(self.context, 'delete_image', {})
         return self.image.delete()
 
+    def deactivate(self):
+        LOG.debug('Attempting deactivate')
+        target = ImageTarget(self.image)
+        self.policy.enforce(self.context, 'deactivate', target=target)
+        LOG.debug('Deactivate allowed, continue')
+        self.image.deactivate()
+
+    def reactivate(self):
+        LOG.debug('Attempting reactivate')
+        target = ImageTarget(self.image)
+        self.policy.enforce(self.context, 'reactivate', target=target)
+        LOG.debug('Reactivate allowed, continue')
+        self.image.reactivate()
+
     def get_data(self, *args, **kwargs):
         target = ImageTarget(self.image)
         self.policy.enforce(self.context, 'download_image',
diff --git a/glance/api/v1/controller.py b/glance/api/v1/controller.py
index c5855b01b6..48b25c5dbe 100644
--- a/glance/api/v1/controller.py
+++ b/glance/api/v1/controller.py
@@ -52,15 +52,22 @@ class BaseController(object):
                                           request=request,
                                           content_type='text/plain')
 
-    def get_active_image_meta_or_404(self, request, image_id):
+    def get_active_image_meta_or_error(self, request, image_id):
         """
-        Same as get_image_meta_or_404 except that it will raise a 404 if the
-        image isn't 'active'.
+        Same as get_image_meta_or_404 except that it will raise a 403 if the
+        image is deactivated or 404 if the image is otherwise not 'active'.
         """
         image = self.get_image_meta_or_404(request, image_id)
+        if image['status'] == 'deactivated':
+            msg = "Image %s is deactivated" % image_id
+            LOG.debug(msg)
+            msg = _("Image %s is deactivated") % image_id
+            raise webob.exc.HTTPForbidden(
+                msg, request=request, content_type='type/plain')
         if image['status'] != 'active':
             msg = "Image %s is not active" % image_id
             LOG.debug(msg)
+            msg = _("Image %s is not active") % image_id
             raise webob.exc.HTTPNotFound(
                 msg, request=request, content_type='text/plain')
         return image
diff --git a/glance/api/v1/images.py b/glance/api/v1/images.py
index 7482b03acf..61200e012f 100644
--- a/glance/api/v1/images.py
+++ b/glance/api/v1/images.py
@@ -476,7 +476,7 @@ class Controller(controller.BaseController):
         self._enforce(req, 'get_image')
 
         try:
-            image_meta = self.get_active_image_meta_or_404(req, id)
+            image_meta = self.get_active_image_meta_or_error(req, id)
         except HTTPNotFound:
             # provision for backward-compatibility breaking issue
             # catch the 404 exception and raise it after enforcing
diff --git a/glance/api/v2/image_actions.py b/glance/api/v2/image_actions.py
new file mode 100644
index 0000000000..37a66acc4d
--- /dev/null
+++ b/glance/api/v2/image_actions.py
@@ -0,0 +1,89 @@
+# Copyright 2015 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.
+import glance_store
+from oslo_log import log as logging
+import webob.exc
+
+from glance.api import policy
+from glance.common import exception
+from glance.common import utils
+from glance.common import wsgi
+import glance.db
+import glance.gateway
+from glance import i18n
+import glance.notifier
+
+
+LOG = logging.getLogger(__name__)
+_ = i18n._
+_LI = i18n._LI
+
+
+class ImageActionsController(object):
+    def __init__(self, db_api=None, policy_enforcer=None, notifier=None,
+                 store_api=None):
+        self.db_api = db_api or glance.db.get_api()
+        self.policy = policy_enforcer or policy.Enforcer()
+        self.notifier = notifier or glance.notifier.Notifier()
+        self.store_api = store_api or glance_store
+        self.gateway = glance.gateway.Gateway(self.db_api, self.store_api,
+                                              self.notifier, self.policy)
+
+    @utils.mutating
+    def deactivate(self, req, image_id):
+        image_repo = self.gateway.get_repo(req.context)
+        try:
+            image = image_repo.get(image_id)
+            image.deactivate()
+            image_repo.save(image)
+            LOG.info(_LI("Image %s is deactivated") % image_id)
+        except exception.NotFound as e:
+            raise webob.exc.HTTPNotFound(explanation=e.msg)
+        except exception.Forbidden as e:
+            raise webob.exc.HTTPForbidden(explanation=e.msg)
+        except exception.InvalidImageStatusTransition as e:
+            raise webob.exc.HTTPBadRequest(explanation=e.msg)
+
+    @utils.mutating
+    def reactivate(self, req, image_id):
+        image_repo = self.gateway.get_repo(req.context)
+        try:
+            image = image_repo.get(image_id)
+            image.reactivate()
+            image_repo.save(image)
+            LOG.info(_LI("Image %s is reactivated") % image_id)
+        except exception.NotFound as e:
+            raise webob.exc.HTTPNotFound(explanation=e.msg)
+        except exception.Forbidden as e:
+            raise webob.exc.HTTPForbidden(explanation=e.msg)
+        except exception.InvalidImageStatusTransition as e:
+            raise webob.exc.HTTPBadRequest(explanation=e.msg)
+
+
+class ResponseSerializer(wsgi.JSONResponseSerializer):
+
+    def deactivate(self, response, result):
+        response.status_int = 204
+
+    def reactivate(self, response, result):
+        response.status_int = 204
+
+
+def create_resource():
+    """Image data resource factory method"""
+    deserializer = None
+    serializer = ResponseSerializer()
+    controller = ImageActionsController()
+    return wsgi.Resource(controller, deserializer, serializer)
diff --git a/glance/api/v2/image_data.py b/glance/api/v2/image_data.py
index 628c37af62..4025eebde0 100644
--- a/glance/api/v2/image_data.py
+++ b/glance/api/v2/image_data.py
@@ -169,6 +169,10 @@ class ImageDataController(object):
         image_repo = self.gateway.get_repo(req.context)
         try:
             image = image_repo.get(image_id)
+            if image.status == 'deactivated':
+                msg = _('The requested image has been deactivated. '
+                        'Image data download is forbidden.')
+                raise exception.Forbidden(message=msg)
             if not image.locations:
                 raise exception.ImageDataNotFound()
         except exception.ImageDataNotFound as e:
diff --git a/glance/api/v2/router.py b/glance/api/v2/router.py
index ec8812ec9c..cb28ad592a 100644
--- a/glance/api/v2/router.py
+++ b/glance/api/v2/router.py
@@ -13,6 +13,7 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+from glance.api.v2 import image_actions
 from glance.api.v2 import image_data
 from glance.api.v2 import image_members
 from glance.api.v2 import image_tags
@@ -447,6 +448,28 @@ class API(wsgi.Router):
                        allowed_methods='GET, PATCH, DELETE',
                        conditions={'method': ['POST', 'PUT', 'HEAD']})
 
+        image_actions_resource = image_actions.create_resource()
+        mapper.connect('/images/{image_id}/actions/deactivate',
+                       controller=image_actions_resource,
+                       action='deactivate',
+                       conditions={'method': ['POST']})
+        mapper.connect('/images/{image_id}/actions/reactivate',
+                       controller=image_actions_resource,
+                       action='reactivate',
+                       conditions={'method': ['POST']})
+        mapper.connect('/images/{image_id}/actions/deactivate',
+                       controller=reject_method_resource,
+                       action='reject',
+                       allowed_methods='POST',
+                       conditions={'method': ['GET', 'PUT', 'DELETE', 'PATCH',
+                                              'HEAD']})
+        mapper.connect('/images/{image_id}/actions/reactivate',
+                       controller=reject_method_resource,
+                       action='reject',
+                       allowed_methods='POST',
+                       conditions={'method': ['GET', 'PUT', 'DELETE', 'PATCH',
+                                              'HEAD']})
+
         image_data_resource = image_data.create_resource()
         mapper.connect('/images/{image_id}/file',
                        controller=image_data_resource,
diff --git a/glance/db/simple/api.py b/glance/db/simple/api.py
index 818b8f64d4..cbf5bfca93 100644
--- a/glance/db/simple/api.py
+++ b/glance/db/simple/api.py
@@ -395,7 +395,7 @@ def _image_get(context, image_id, force_show_deleted=False, status=None):
 @log_call
 def image_get(context, image_id, session=None, force_show_deleted=False):
     image = _image_get(context, image_id, force_show_deleted)
-    return _normalize_locations(copy.deepcopy(image),
+    return _normalize_locations(context, copy.deepcopy(image),
                                 force_show_deleted=force_show_deleted)
 
 
@@ -415,7 +415,7 @@ def image_get_all(context, filters=None, marker=None, limit=None,
     force_show_deleted = True if filters.get('deleted') else False
     res = []
     for image in images:
-        img = _normalize_locations(copy.deepcopy(image),
+        img = _normalize_locations(context, copy.deepcopy(image),
                                    force_show_deleted=force_show_deleted)
         if return_tag:
             img['tags'] = image_tag_get_all(context, img['id'])
@@ -622,7 +622,7 @@ def _image_locations_delete_all(context, image_id, delete_time=None):
             del DATA['locations'][i]
 
 
-def _normalize_locations(image, force_show_deleted=False):
+def _normalize_locations(context, image, force_show_deleted=False):
     """
     Generate suitable dictionary list for locations field of image.
 
@@ -630,6 +630,11 @@ def _normalize_locations(image, force_show_deleted=False):
     from image query.
     """
 
+    if image['status'] == 'deactivated' and not context.is_admin:
+        # Locations are not returned for a deactivated image for non-admin user
+        image['locations'] = []
+        return image
+
     if force_show_deleted:
         locations = image['locations']
     else:
@@ -668,7 +673,7 @@ def image_create(context, image_values):
     DATA['images'][image_id] = image
     DATA['tags'][image_id] = image.pop('tags', [])
 
-    return _normalize_locations(copy.deepcopy(image))
+    return _normalize_locations(context, copy.deepcopy(image))
 
 
 @log_call
@@ -696,7 +701,7 @@ def image_update(context, image_id, image_values, purge_props=False,
     image['updated_at'] = timeutils.utcnow()
     _image_update(image, image_values, new_properties)
     DATA['images'][image_id] = image
-    return _normalize_locations(copy.deepcopy(image))
+    return _normalize_locations(context, copy.deepcopy(image))
 
 
 @log_call
@@ -727,7 +732,8 @@ def image_destroy(context, image_id):
         for tag in tags:
             image_tag_delete(context, image_id, tag)
 
-        return _normalize_locations(copy.deepcopy(DATA['images'][image_id]))
+        return _normalize_locations(context,
+                                    copy.deepcopy(DATA['images'][image_id]))
     except KeyError:
         raise exception.NotFound()
 
diff --git a/glance/db/sqlalchemy/api.py b/glance/db/sqlalchemy/api.py
index 0f21e89444..e6da75e6f9 100644
--- a/glance/db/sqlalchemy/api.py
+++ b/glance/db/sqlalchemy/api.py
@@ -58,7 +58,7 @@ _LW = i18n._LW
 
 
 STATUSES = ['active', 'saving', 'queued', 'killed', 'pending_delete',
-            'deleted']
+            'deleted', 'deactivated']
 
 CONF = cfg.CONF
 CONF.import_group("profiler", "glance.common.wsgi")
@@ -159,10 +159,10 @@ def image_destroy(context, image_id):
 
         _image_tag_delete_all(context, image_id, delete_time, session)
 
-    return _normalize_locations(image_ref)
+    return _normalize_locations(context, image_ref)
 
 
-def _normalize_locations(image, force_show_deleted=False):
+def _normalize_locations(context, image, force_show_deleted=False):
     """
     Generate suitable dictionary list for locations field of image.
 
@@ -170,6 +170,11 @@ def _normalize_locations(image, force_show_deleted=False):
     from image query.
     """
 
+    if image['status'] == 'deactivated' and not context.is_admin:
+        # Locations are not returned for a deactivated image for non-admin user
+        image['locations'] = []
+        return image
+
     if force_show_deleted:
         locations = image['locations']
     else:
@@ -191,7 +196,7 @@ def _normalize_tags(image):
 def image_get(context, image_id, session=None, force_show_deleted=False):
     image = _image_get(context, image_id, session=session,
                        force_show_deleted=force_show_deleted)
-    image = _normalize_locations(image.to_dict(),
+    image = _normalize_locations(context, image.to_dict(),
                                  force_show_deleted=force_show_deleted)
     return image
 
@@ -616,7 +621,7 @@ def image_get_all(context, filters=None, marker=None, limit=None,
     images = []
     for image in query.all():
         image_dict = image.to_dict()
-        image_dict = _normalize_locations(image_dict,
+        image_dict = _normalize_locations(context, image_dict,
                                           force_show_deleted=showing_deleted)
         if return_tag:
             image_dict = _normalize_tags(image_dict)
diff --git a/glance/domain/__init__.py b/glance/domain/__init__.py
index 9225c1e464..5ac56d261a 100644
--- a/glance/domain/__init__.py
+++ b/glance/domain/__init__.py
@@ -108,10 +108,11 @@ class Image(object):
         # can be retried.
         'queued': ('saving', 'active', 'deleted'),
         'saving': ('active', 'killed', 'deleted', 'queued'),
-        'active': ('queued', 'pending_delete', 'deleted'),
+        'active': ('queued', 'pending_delete', 'deleted', 'deactivated'),
         'killed': ('deleted',),
         'pending_delete': ('deleted',),
         'deleted': (),
+        'deactivated': ('active', 'deleted'),
     }
 
     def __init__(self, image_id, status, created_at, updated_at, **kwargs):
@@ -247,6 +248,34 @@ class Image(object):
         else:
             self.status = 'deleted'
 
+    def deactivate(self):
+        if self.status == 'active':
+            self.status = 'deactivated'
+        elif self.status == 'deactivated':
+            # Noop if already deactive
+            pass
+        else:
+            msg = ("Not allowed to deactivate image in status '%s'"
+                   % self.status)
+            LOG.debug(msg)
+            msg = (_("Not allowed to deactivate image in status '%s'")
+                   % self.status)
+            raise exception.Forbidden(message=msg)
+
+    def reactivate(self):
+        if self.status == 'deactivated':
+            self.status = 'active'
+        elif self.status == 'active':
+            # Noop if already active
+            pass
+        else:
+            msg = ("Not allowed to reactivate image in status '%s'"
+                   % self.status)
+            LOG.debug(msg)
+            msg = (_("Not allowed to reactivate image in status '%s'")
+                   % self.status)
+            raise exception.Forbidden(message=msg)
+
     def get_data(self, *args, **kwargs):
         raise NotImplementedError()
 
diff --git a/glance/domain/proxy.py b/glance/domain/proxy.py
index 4df85caa06..c9ce3e4bd7 100644
--- a/glance/domain/proxy.py
+++ b/glance/domain/proxy.py
@@ -156,6 +156,12 @@ class Image(object):
     def delete(self):
         self.base.delete()
 
+    def deactivate(self):
+        self.base.deactivate()
+
+    def reactivate(self):
+        self.base.reactivate()
+
     def set_data(self, data, size=None):
         self.base.set_data(data, size)
 
diff --git a/glance/tests/etc/policy.json b/glance/tests/etc/policy.json
index c5858eacee..8dd0d1dc89 100644
--- a/glance/tests/etc/policy.json
+++ b/glance/tests/etc/policy.json
@@ -54,5 +54,8 @@
     "get_metadef_tags":"",
     "modify_metadef_tag":"",
     "add_metadef_tag":"",
-    "add_metadef_tags":""
+    "add_metadef_tags":"",
+
+    "deactivate": "",
+    "reactivate": ""
 }
diff --git a/glance/tests/functional/test_cache_middleware.py b/glance/tests/functional/test_cache_middleware.py
index 7413d115e5..a403b40c47 100644
--- a/glance/tests/functional/test_cache_middleware.py
+++ b/glance/tests/functional/test_cache_middleware.py
@@ -332,6 +332,100 @@ class BaseCacheMiddlewareTest(object):
 
         self.stop_servers()
 
+    @skip_if_disabled
+    def test_cache_middleware_trans_with_deactivated_image(self):
+        """
+        Ensure the image v1/v2 API image transfer forbids downloading
+        deactivated images.
+        Image deactivation is not available in v1. So, we'll deactivate the
+        image using v2 but test image transfer with both v1 and v2.
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # Add an image and verify a 200 OK is returned
+        image_data = "*" * FIVE_KB
+        headers = minimal_headers('Image1')
+        path = "http://%s:%d/v1/images" % ("127.0.0.1", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers,
+                                         body=image_data)
+        self.assertEqual(201, response.status)
+        data = jsonutils.loads(content)
+        self.assertEqual(hashlib.md5(image_data).hexdigest(),
+                         data['image']['checksum'])
+        self.assertEqual(FIVE_KB, data['image']['size'])
+        self.assertEqual("Image1", data['image']['name'])
+        self.assertTrue(data['image']['is_public'])
+
+        image_id = data['image']['id']
+
+        # Grab the image
+        path = "http://%s:%d/v1/images/%s" % ("127.0.0.1", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(200, response.status)
+
+        # Verify image in cache
+        image_cached_path = os.path.join(self.api_server.image_cache_dir,
+                                         image_id)
+        self.assertTrue(os.path.exists(image_cached_path))
+
+        # Deactivate the image using v2
+        path = "http://%s:%d/v2/images/%s/actions/deactivate"
+        path = path % ("127.0.0.1", self.api_port, image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST')
+        self.assertEqual(204, response.status)
+
+        # Download the image with v1. Ensure it is forbidden
+        path = "http://%s:%d/v1/images/%s" % ("127.0.0.1", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(403, response.status)
+
+        # Download the image with v2. Ensure it is forbidden
+        path = "http://%s:%d/v2/images/%s/file" % ("127.0.0.1", self.api_port,
+                                                   image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(403, response.status)
+
+        # Reactivate the image using v2
+        path = "http://%s:%d/v2/images/%s/actions/reactivate"
+        path = path % ("127.0.0.1", self.api_port, image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST')
+        self.assertEqual(204, response.status)
+
+        # Download the image with v1. Ensure it is allowed
+        path = "http://%s:%d/v1/images/%s" % ("127.0.0.1", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(200, response.status)
+
+        # Download the image with v2. Ensure it is allowed
+        path = "http://%s:%d/v2/images/%s/file" % ("127.0.0.1", self.api_port,
+                                                   image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(200, response.status)
+
+        # Now, we delete the image from the server and verify that
+        # the image cache no longer contains the deleted image
+        path = "http://%s:%d/v1/images/%s" % ("127.0.0.1", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'DELETE')
+        self.assertEqual(200, response.status)
+
+        self.assertFalse(os.path.exists(image_cached_path))
+
+        self.stop_servers()
+
 
 class BaseCacheManageMiddlewareTest(object):
 
diff --git a/glance/tests/functional/v2/test_images.py b/glance/tests/functional/v2/test_images.py
index 8777857578..4fc2ece600 100644
--- a/glance/tests/functional/v2/test_images.py
+++ b/glance/tests/functional/v2/test_images.py
@@ -410,6 +410,40 @@ class TestImages(functional.FunctionalTest):
         self.assertEqual(200, response.status_code)
         self.assertEqual(5, jsonutils.loads(response.text)['size'])
 
+        # Should be able to deactivate image
+        path = self._url('/v2/images/%s/actions/deactivate' % image_id)
+        response = requests.post(path, data={}, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
+        # Deactivating a deactivated image succeeds (no-op)
+        path = self._url('/v2/images/%s/actions/deactivate' % image_id)
+        response = requests.post(path, data={}, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
+        # Can't download a deactivated image
+        path = self._url('/v2/images/%s/file' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(403, response.status_code)
+
+        # Deactivated image should still be in a listing
+        path = self._url('/v2/images')
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        images = jsonutils.loads(response.text)['images']
+        self.assertEqual(2, len(images))
+        self.assertEqual(image2_id, images[0]['id'])
+        self.assertEqual(image_id, images[1]['id'])
+
+        # Should be able to reactivate a deactivated image
+        path = self._url('/v2/images/%s/actions/reactivate' % image_id)
+        response = requests.post(path, data={}, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
+        # Reactivating an active image succeeds (no-op)
+        path = self._url('/v2/images/%s/actions/reactivate' % image_id)
+        response = requests.post(path, data={}, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
         # Deletion should not work on protected images
         path = self._url('/v2/images/%s' % image_id)
         response = requests.delete(path, headers=self._headers())
diff --git a/glance/tests/unit/test_cache_middleware.py b/glance/tests/unit/test_cache_middleware.py
index c6bc92acee..227a135b3b 100644
--- a/glance/tests/unit/test_cache_middleware.py
+++ b/glance/tests/unit/test_cache_middleware.py
@@ -223,7 +223,7 @@ class TestCacheMiddlewareProcessRequest(base.IsolatedUnitTest):
             raise exception.NotFound()
 
         def fake_get_v1_image_metadata(request, image_id):
-            return {'properties': {}}
+            return {'status': 'active', 'properties': {}}
 
         image_id = 'test1'
         request = webob.Request.blank('/v1/images/%s' % image_id)
@@ -386,7 +386,7 @@ class TestCacheMiddlewareProcessRequest(base.IsolatedUnitTest):
         """
 
         def fake_get_v1_image_metadata(*args, **kwargs):
-            return {'properties': {}}
+            return {'status': 'active', 'properties': {}}
 
         image_id = 'test1'
         request = webob.Request.blank('/v1/images/%s' % image_id)
diff --git a/glance/tests/unit/v1/test_api.py b/glance/tests/unit/v1/test_api.py
index b1f9376df0..11e278a4cb 100644
--- a/glance/tests/unit/v1/test_api.py
+++ b/glance/tests/unit/v1/test_api.py
@@ -52,6 +52,7 @@ _gen_uuid = lambda: str(uuid.uuid4())
 
 UUID1 = _gen_uuid()
 UUID2 = _gen_uuid()
+UUID3 = _gen_uuid()
 
 
 class TestGlanceAPI(base.IsolatedUnitTest):
@@ -90,6 +91,21 @@ class TestGlanceAPI(base.IsolatedUnitTest):
              'size': 19,
              'locations': [{'url': "file:///%s/%s" % (self.test_dir, UUID2),
                             'metadata': {}, 'status': 'active'}],
+             'properties': {}},
+            {'id': UUID3,
+             'name': 'fake image #3',
+             'status': 'deactivated',
+             'disk_format': 'ami',
+             'container_format': 'ami',
+             'is_public': False,
+             'created_at': timeutils.utcnow(),
+             'updated_at': timeutils.utcnow(),
+             'deleted_at': None,
+             'deleted': False,
+             'checksum': None,
+             'size': 13,
+             'locations': [{'url': "file:///%s/%s" % (self.test_dir, UUID1),
+                            'metadata': {}, 'status': 'active'}],
              'properties': {}}]
         self.context = glance.context.RequestContext(is_admin=True)
         db_api.get_engine()
@@ -1296,6 +1312,13 @@ class TestGlanceAPI(base.IsolatedUnitTest):
         """Tests delayed activation of image with missing container format"""
         self._do_test_put_image_content_missing_format('container_format')
 
+    def test_download_deactivated_images(self):
+        """Tests exception raised trying to download a deactivated image"""
+        req = webob.Request.blank("/images/%s" % UUID3)
+        req.method = 'GET'
+        res = req.get_response(self.api)
+        self.assertEqual(403, res.status_int)
+
     def test_update_deleted_image(self):
         """Tests that exception raised trying to update a deleted image"""
         req = webob.Request.blank("/images/%s" % UUID2)
@@ -2615,7 +2638,7 @@ class TestGlanceAPI(base.IsolatedUnitTest):
 
         image_controller = glance.api.v1.images.Controller()
         with mock.patch.object(image_controller,
-                               'get_active_image_meta_or_404'
+                               'get_active_image_meta_or_error'
                                ) as mocked_get_image:
             mocked_get_image.return_value = image_fixture
             self.assertRaises(webob.exc.HTTPServiceUnavailable,
diff --git a/glance/tests/unit/v2/test_image_actions_resource.py b/glance/tests/unit/v2/test_image_actions_resource.py
new file mode 100644
index 0000000000..bb5b4cac9e
--- /dev/null
+++ b/glance/tests/unit/v2/test_image_actions_resource.py
@@ -0,0 +1,189 @@
+# Copyright 2015 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.
+import glance_store as store
+import webob
+
+import glance.api.v2.image_actions as image_actions
+import glance.context
+from glance.tests.unit import base
+import glance.tests.unit.utils as unit_test_utils
+
+
+BASE_URI = unit_test_utils.BASE_URI
+
+USER1 = '54492ba0-f4df-4e4e-be62-27f4d76b29cf'
+UUID1 = 'c80a1a6c-bd1f-41c5-90ee-81afedb1d58d'
+TENANT1 = '6838eb7b-6ded-434a-882c-b344c77fe8df'
+CHKSUM = '93264c3edf5972c9f1cb309543d38a5c'
+
+
+def _db_fixture(id, **kwargs):
+    obj = {
+        'id': id,
+        'name': None,
+        'is_public': False,
+        'properties': {},
+        'checksum': None,
+        'owner': None,
+        'status': 'queued',
+        'tags': [],
+        'size': None,
+        'virtual_size': None,
+        'locations': [],
+        'protected': False,
+        'disk_format': None,
+        'container_format': None,
+        'deleted': False,
+        'min_ram': None,
+        'min_disk': None,
+    }
+    obj.update(kwargs)
+    return obj
+
+
+class TestImageActionsController(base.IsolatedUnitTest):
+    def setUp(self):
+        super(TestImageActionsController, self).setUp()
+        self.db = unit_test_utils.FakeDB()
+        self.policy = unit_test_utils.FakePolicyEnforcer()
+        self.notifier = unit_test_utils.FakeNotifier()
+        self.store = unit_test_utils.FakeStoreAPI()
+        for i in range(1, 4):
+            self.store.data['%s/fake_location_%i' % (BASE_URI, i)] = ('Z', 1)
+        self.store_utils = unit_test_utils.FakeStoreUtils(self.store)
+        self.controller = image_actions.ImageActionsController(
+            self.db,
+            self.policy,
+            self.notifier,
+            self.store)
+        self.controller.gateway.store_utils = self.store_utils
+        store.create_stores()
+
+    def _get_fake_context(self, user=USER1, tenant=TENANT1, roles=['member'],
+                          is_admin=False):
+        kwargs = {
+            'user': user,
+            'tenant': tenant,
+            'roles': roles,
+            'is_admin': is_admin,
+        }
+
+        context = glance.context.RequestContext(**kwargs)
+        return context
+
+    def _create_image(self, status):
+        self.db.reset()
+        self.images = [
+            _db_fixture(UUID1, owner=TENANT1, checksum=CHKSUM,
+                        name='1', size=256, virtual_size=1024,
+                        is_public=True,
+                        locations=[{'url': '%s/%s' % (BASE_URI, UUID1),
+                                    'metadata': {}, 'status': 'active'}],
+                        disk_format='raw',
+                        container_format='bare',
+                        status=status),
+        ]
+        context = self._get_fake_context()
+        [self.db.image_create(context, image) for image in self.images]
+
+    def test_deactivate_from_active(self):
+        self._create_image('active')
+
+        request = unit_test_utils.get_fake_request()
+        self.controller.deactivate(request, UUID1)
+
+        image = self.db.image_get(request.context, UUID1)
+
+        self.assertEqual('deactivated', image['status'])
+
+    def test_deactivate_from_deactivated(self):
+        self._create_image('deactivated')
+
+        request = unit_test_utils.get_fake_request()
+        self.controller.deactivate(request, UUID1)
+
+        image = self.db.image_get(request.context, UUID1)
+
+        self.assertEqual('deactivated', image['status'])
+
+    def _test_deactivate_from_wrong_status(self, status):
+
+        # deactivate will yield an error if the initial status is anything
+        # other than 'active' or 'deactivated'
+        self._create_image(status)
+
+        request = unit_test_utils.get_fake_request()
+        self.assertRaises(webob.exc.HTTPForbidden, self.controller.deactivate,
+                          request, UUID1)
+
+    def test_deactivate_from_queued(self):
+        self._test_deactivate_from_wrong_status('queued')
+
+    def test_deactivate_from_saving(self):
+        self._test_deactivate_from_wrong_status('saving')
+
+    def test_deactivate_from_killed(self):
+        self._test_deactivate_from_wrong_status('killed')
+
+    def test_deactivate_from_pending_delete(self):
+        self._test_deactivate_from_wrong_status('pending_delete')
+
+    def test_deactivate_from_deleted(self):
+        self._test_deactivate_from_wrong_status('deleted')
+
+    def test_reactivate_from_active(self):
+        self._create_image('active')
+
+        request = unit_test_utils.get_fake_request()
+        self.controller.reactivate(request, UUID1)
+
+        image = self.db.image_get(request.context, UUID1)
+
+        self.assertEqual('active', image['status'])
+
+    def test_reactivate_from_deactivated(self):
+        self._create_image('deactivated')
+
+        request = unit_test_utils.get_fake_request()
+        self.controller.reactivate(request, UUID1)
+
+        image = self.db.image_get(request.context, UUID1)
+
+        self.assertEqual('active', image['status'])
+
+    def _test_reactivate_from_wrong_status(self, status):
+
+        # reactivate will yield an error if the initial status is anything
+        # other than 'active' or 'deactivated'
+        self._create_image(status)
+
+        request = unit_test_utils.get_fake_request()
+        self.assertRaises(webob.exc.HTTPForbidden, self.controller.reactivate,
+                          request, UUID1)
+
+    def test_reactivate_from_queued(self):
+        self._test_reactivate_from_wrong_status('queued')
+
+    def test_reactivate_from_saving(self):
+        self._test_reactivate_from_wrong_status('saving')
+
+    def test_reactivate_from_killed(self):
+        self._test_reactivate_from_wrong_status('killed')
+
+    def test_reactivate_from_pending_delete(self):
+        self._test_reactivate_from_wrong_status('pending_delete')
+
+    def test_reactivate_from_deleted(self):
+        self._test_reactivate_from_wrong_status('deleted')
diff --git a/glance/tests/unit/v2/test_image_data_resource.py b/glance/tests/unit/v2/test_image_data_resource.py
index 22c9f610f0..afe942509b 100644
--- a/glance/tests/unit/v2/test_image_data_resource.py
+++ b/glance/tests/unit/v2/test_image_data_resource.py
@@ -110,6 +110,16 @@ class TestImagesController(base.StoreClearingUnitTest):
         image = self.controller.download(request, unit_test_utils.UUID1)
         self.assertEqual('abcd', image.image_id)
 
+    def test_download_deactivated(self):
+        request = unit_test_utils.get_fake_request()
+        image = FakeImage('abcd',
+                          status='deactivated',
+                          locations=[{'url': 'http://example.com/image',
+                                      'metadata': {}, 'status': 'active'}])
+        self.image_repo.result = image
+        self.assertRaises(webob.exc.HTTPForbidden, self.controller.download,
+                          request, str(uuid.uuid4()))
+
     def test_download_no_location(self):
         request = unit_test_utils.get_fake_request()
         self.image_repo.result = FakeImage('abcd')