Repository: deis/deis

Issue:
deis apps are locked in with creator as owner
Just wanted to make sure you guys are aware of this.

currently Deis is coded so Apps are locked in to the creator who owned it. Seems its not the intended implementation. Here are the lines of code on controller:
https://github.com/deis/deis/blob/master/controller/api/views.py#L217-L218
https://github.com/deis/deis/blob/master/controller/api/views.py#L266

Here's the IRC disucssion from last week:
https://botbot.me/freenode/deis/2014-06-25/?msg=16938136&page=1



Discussion:
cc @gabrtv 

Update: I've been adding tests around this workflow, and for the most part superusers of the cluster already have access to all of the apps and have "godmode" access. The views just [restrict your viewing pane](https://github.com/deis/deis/blob/master/controller/api/views.py#L274-L275) to the app owners and anyone with sharing access. For some reason, superusers cannot delete apps and perms, though, so I am investigating that.


Resolving patch:
diff --git a/controller/api/permissions.py b/controller/api/permissions.py
new file mode 100644
--- /dev/null
+++ b/controller/api/permissions.py
@@ -0,0 +1,99 @@
+from rest_framework import permissions
+from django.conf import settings
+from django.contrib.auth.models import AnonymousUser
+
+from api import models
+
+
+class IsAnonymous(permissions.BasePermission):
+    """
+    View permission to allow anonymous users.
+    """
+
+    def has_permission(self, request, view):
+        """
+        Return `True` if permission is granted, `False` otherwise.
+        """
+        return type(request.user) is AnonymousUser
+
+
+class IsOwner(permissions.BasePermission):
+    """
+    Object-level permission to allow only owners of an object to access it.
+    Assumes the model instance has an `owner` attribute.
+    """
+
+    def has_object_permission(self, request, view, obj):
+        if hasattr(obj, 'owner'):
+            return obj.owner == request.user
+        else:
+            return False
+
+
+class IsAppUser(permissions.BasePermission):
+    """
+    Object-level permission to allow owners or collaborators to access
+    an app-related model.
+    """
+    def has_object_permission(self, request, view, obj):
+        if isinstance(obj, models.App) and obj.owner == request.user:
+            return True
+        elif hasattr(obj, 'app') and obj.app.owner == request.user:
+            return True
+        elif request.user.has_perm('use_app', obj):
+            return request.method != 'DELETE'
+        elif hasattr(obj, 'app') and request.user.has_perm('use_app', obj.app):
+            return request.method != 'DELETE'
+        else:
+            return False
+
+
+class IsAdmin(permissions.BasePermission):
+    """
+    View permission to allow only admins.
+    """
+
+    def has_permission(self, request, view):
+        """
+        Return `True` if permission is granted, `False` otherwise.
+        """
+        return request.user.is_superuser
+
+
+class IsAdminOrSafeMethod(permissions.BasePermission):
+    """
+    View permission to allow only admins to use unsafe methods
+    including POST, PUT, DELETE.
+
+    This allows
+    """
+
+    def has_permission(self, request, view):
+        """
+        Return `True` if permission is granted, `False` otherwise.
+        """
+        return request.method in permissions.SAFE_METHODS or request.user.is_superuser
+
+
+class HasRegistrationAuth(permissions.BasePermission):
+    """
+    Checks to see if registration is enabled
+    """
+    def has_permission(self, request, view):
+        return settings.REGISTRATION_ENABLED
+
+
+class HasBuilderAuth(permissions.BasePermission):
+    """
+    View permission to allow builder to perform actions
+    with a special HTTP header
+    """
+
+    def has_permission(self, request, view):
+        """
+        Return `True` if permission is granted, `False` otherwise.
+        """
+        auth_header = request.environ.get('HTTP_X_DEIS_BUILDER_AUTH')
+        if not auth_header:
+            return False
+        return auth_header == settings.BUILDER_KEY
diff --git a/controller/api/views.py b/controller/api/views.py
--- a/controller/api/views.py
+++ b/controller/api/views.py
@@ -6,8 +6,7 @@
 from __future__ import unicode_literals
 import json
 
-from django.contrib.auth.models import AnonymousUser
-from django.contrib.auth.models import User
+from django.contrib.auth.models import AnonymousUser, User
 from django.core.exceptions import ValidationError
 from django.http import Http404
 from django.utils import timezone
@@ -24,8 +23,8 @@
 from rest_framework.response import Response
 
 from api import models, serializers
-
-from django.conf import settings
+from api.permissions import IsAnonymous, IsOwner, IsAppUser, \
+    IsAdmin, HasRegistrationAuth, HasBuilderAuth
 
 
 class AnonymousAuthentication(BaseAuthentication):
@@ -38,100 +37,6 @@ def authenticate(self, request):
         return user, None
 
 
-class IsAnonymous(permissions.BasePermission):
-    """
-    View permission to allow anonymous users.
-    """
-
-    def has_permission(self, request, view):
-        """
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        return type(request.user) is AnonymousUser
-
-
-class IsOwner(permissions.BasePermission):
-    """
-    Object-level permission to allow only owners of an object to access it.
-    Assumes the model instance has an `owner` attribute.
-    """
-
-    def has_object_permission(self, request, view, obj):
-        if hasattr(obj, 'owner'):
-            return obj.owner == request.user
-        else:
-            return False
-
-
-class IsAppUser(permissions.BasePermission):
-    """
-    Object-level permission to allow owners or collaborators to access
-    an app-related model.
-    """
-    def has_object_permission(self, request, view, obj):
-        if isinstance(obj, models.App) and obj.owner == request.user:
-            return True
-        elif hasattr(obj, 'app') and obj.app.owner == request.user:
-            return True
-        elif request.user.has_perm('use_app', obj):
-            return request.method != 'DELETE'
-        elif hasattr(obj, 'app') and request.user.has_perm('use_app', obj.app):
-            return request.method != 'DELETE'
-        else:
-            return False
-
-
-class IsAdmin(permissions.BasePermission):
-    """
-    View permission to allow only admins.
-    """
-
-    def has_permission(self, request, view):
-        """
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        return request.user.is_superuser
-
-
-class IsAdminOrSafeMethod(permissions.BasePermission):
-    """
-    View permission to allow only admins to use unsafe methods
-    including POST, PUT, DELETE.
-
-    This allows
-    """
-
-    def has_permission(self, request, view):
-        """
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        return request.method in permissions.SAFE_METHODS or request.user.is_superuser
-
-
-class HasRegistrationAuth(permissions.BasePermission):
-    """
-    Checks to see if registration is enabled
-    """
-    def has_permission(self, request, view):
-        return settings.REGISTRATION_ENABLED
-
-
-class HasBuilderAuth(permissions.BasePermission):
-    """
-    View permission to allow builder to perform actions
-    with a special HTTP header
-    """
-
-    def has_permission(self, request, view):
-        """
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        auth_header = request.environ.get('HTTP_X_DEIS_BUILDER_AUTH')
-        if not auth_header:
-            return False
-        return auth_header == settings.BUILDER_KEY
-
-
 class UserRegistrationView(viewsets.GenericViewSet,
                            viewsets.mixins.CreateModelMixin):
     model = User
@@ -156,7 +61,6 @@ def pre_save(self, obj):
 class UserCancellationView(viewsets.GenericViewSet,
                            viewsets.mixins.DestroyModelMixin):
     model = User
-
     permission_classes = (permissions.IsAuthenticated,)
 
     def destroy(self, request, *args, **kwargs):
@@ -208,7 +112,9 @@ class AppPermsViewSet(viewsets.ViewSet):
     def list(self, request, **kwargs):
         app = get_object_or_404(self.model, id=kwargs['id'])
         perm_name = "api.{}".format(self.perm)
-        if request.user != app.owner and not request.user.has_perm(perm_name, app):
+        if request.user != app.owner and \
+                not request.user.has_perm(perm_name, app) and \
+                not request.user.is_superuser:
             return Response(status=status.HTTP_403_FORBIDDEN)
         usernames = [u.username for u in get_users_with_perms(app)
                      if u.has_perm(perm_name, app)]
@@ -216,7 +122,7 @@ def list(self, request, **kwargs):
 
     def create(self, request, **kwargs):
         app = get_object_or_404(self.model, id=kwargs['id'])
-        if request.user != app.owner:
+        if request.user != app.owner and not request.user.is_superuser:
             return Response(status=status.HTTP_403_FORBIDDEN)
         user = get_object_or_404(User, username=request.DATA['username'])
         assign_perm(self.perm, user, app)
@@ -225,7 +131,7 @@ def create(self, request, **kwargs):
 
     def destroy(self, request, **kwargs):
         app = get_object_or_404(self.model, id=kwargs['id'])
-        if request.user != app.owner:
+        if request.user != app.owner and not request.user.is_superuser:
             return Response(status=status.HTTP_403_FORBIDDEN)
         user = get_object_or_404(User, username=kwargs['username'])
         if user.has_perm(self.perm, app):
@@ -317,6 +223,11 @@ def run(self, request, **kwargs):
         return Response(output_and_rc, status=status.HTTP_200_OK,
                         content_type='text/plain')
 
+    def destroy(self, request, **kwargs):
+        obj = get_object_or_404(self.model, id=kwargs['id'])
+        obj.delete()
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
 
 class BaseAppViewSet(viewsets.ModelViewSet):
 
@@ -519,7 +430,9 @@ def create(self, request, *args, **kwargs):
         user = get_object_or_404(
             User, username=request.DATA['receive_user'])
         # check the user is authorized for this app
-        if user == app.owner or user in get_users_with_perms(app):
+        if user == app.owner or \
+           user in get_users_with_perms(app) or \
+           user.is_superuser:
             request._data = request.DATA.copy()
             request.DATA['app'] = app
             request.DATA['owner'] = user
@@ -538,7 +451,9 @@ def create(self, request, *args, **kwargs):
         user = get_object_or_404(
             User, username=request.DATA['receive_user'])
         # check the user is authorized for this app
-        if user == app.owner or user in get_users_with_perms(app):
+        if user == app.owner or \
+           user in get_users_with_perms(app) or \
+           user.is_superuser:
             request._data = request.DATA.copy()
             request.DATA['app'] = app
             request.DATA['owner'] = user
@@ -568,7 +483,9 @@ def create(self, request, *args, **kwargs):
         user = get_object_or_404(
             User, username=request.DATA['receive_user'])
         # check the user is authorized for this app
-        if user == app.owner or user in get_users_with_perms(app):
+        if user == app.owner or \
+           user in get_users_with_perms(app) or \
+           user.is_superuser:
             config = app.release_set.latest().config
             serializer = self.get_serializer(config)
             return Response(serializer.data, status=status.HTTP_200_OK)


Test patch:
diff --git a/controller/api/fixtures/test_sharing.json b/controller/api/fixtures/test_sharing.json
--- a/controller/api/fixtures/test_sharing.json
+++ b/controller/api/fixtures/test_sharing.json
@@ -25,8 +25,8 @@
     "first_name": "",
     "last_name": "",
     "is_active": true,
-    "is_superuser": false,
-    "is_staff": false,
+    "is_superuser": true,
+    "is_staff": true,
     "last_login": "2013-11-25T21:58:47.420Z",
     "groups": [],
     "user_permissions": [],
@@ -77,5 +77,16 @@
     "owner": 2,
     "id": "autotest-1-app"
   }
+},
+{
+  "pk": "5a09a1e0-a27e-4839-928b-449310ed90f1",
+  "model": "api.app",
+  "fields": {
+    "updated": "2013-11-25T22:09:36.726Z",
+    "created": "2013-11-25T22:09:36.726Z",
+    "cluster": "80e9db5a-d394-41c7-b153-3ea0314dc8ca",
+    "owner": 3,
+    "id": "autotest-2-app"
+  }
 }
 ]
diff --git a/controller/api/tests/test_app.py b/controller/api/tests/test_app.py
--- a/controller/api/tests/test_app.py
+++ b/controller/api/tests/test_app.py
@@ -90,6 +90,7 @@ def test_app_actions(self):
         response = self.client.post(url)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.data, FAKE_LOG_DATA)
+        os.remove(path)
         # test run
         url = '/api/apps/{app_id}/run'.format(**locals())
         body = {'command': 'ls -al'}
@@ -136,6 +137,55 @@ def test_app_errors(self):
             response = self.client.get(url)
             self.assertEquals(response.status_code, 404)
 
+    def test_admin_can_manage_other_apps(self):
+        """Administrators of Deis should be able to manage all applications.
+        """
+        # log in as non-admin user and create an app
+        self.assertTrue(
+            self.client.login(username='autotest2', password='password'))
+        app_id = 'autotest'
+        url = '/api/apps'
+        body = {'cluster': 'autotest', 'id': app_id}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        # log in as admin, check to see if they have access
+        self.assertTrue(
+            self.client.login(username='autotest', password='password'))
+        url = '/api/apps/{}'.format(app_id)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+        # check app logs
+        url = '/api/apps/{app_id}/logs'.format(**locals())
+        response = self.client.post(url)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('autotest2 created initial release', response.data)
+        # run one-off commands
+        url = '/api/apps/{app_id}/run'.format(**locals())
+        body = {'command': 'ls -al'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data[0], 0)
+        # delete the app
+        url = '/api/apps/{}'.format(app_id)
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, 204)
+
+    def test_admin_can_see_other_apps(self):
+        """If a user creates an application, the administrator should be able
+        to see it.
+        """
+        # log in as non-admin user and create an app
+        self.assertTrue(
+            self.client.login(username='autotest2', password='password'))
+        app_id = 'autotest'
+        url = '/api/apps'
+        body = {'cluster': 'autotest', 'id': app_id}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        # log in as admin
+        self.assertTrue(
+            self.client.login(username='autotest', password='password'))
+        response = self.client.get(url)
+        self.assertEqual(response.data['count'], 1)
+
 
 FAKE_LOG_DATA = """
 2013-08-15 12:41:25 [33454] [INFO] Starting gunicorn 17.5
diff --git a/controller/api/tests/test_build.py b/controller/api/tests/test_build.py
--- a/controller/api/tests/test_build.py
+++ b/controller/api/tests/test_build.py
@@ -179,3 +179,25 @@ def test_build_str(self):
         build = Build.objects.get(uuid=response.data['uuid'])
         self.assertEqual(str(build), "{}-{}".format(
                          response.data['app'], response.data['uuid'][:7]))
+
+    @mock.patch('requests.post', mock_import_repository_task)
+    def test_admin_can_create_builds_on_other_apps(self):
+        """If a user creates an application, an administrator should be able
+        to push builds.
+        """
+        # create app as non-admin
+        self.client.login(username='autotest2', password='password')
+        url = '/api/apps'
+        body = {'cluster': 'autotest'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        app_id = response.data['id']
+        # post a new build as admin
+        self.client.login(username='autotest', password='password')
+        url = "/api/apps/{app_id}/builds".format(**locals())
+        body = {'image': 'autotest/example'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        build = Build.objects.get(uuid=response.data['uuid'])
+        self.assertEqual(str(build), "{}-{}".format(
+                         response.data['app'], response.data['uuid'][:7]))
diff --git a/controller/api/tests/test_config.py b/controller/api/tests/test_config.py
--- a/controller/api/tests/test_config.py
+++ b/controller/api/tests/test_config.py
@@ -134,6 +134,25 @@ def test_config_str(self):
         config = Config.objects.get(uuid=config5['uuid'])
         self.assertEqual(str(config), "{}-{}".format(config5['app'], config5['uuid'][:7]))
 
+    @mock.patch('requests.post', mock_import_repository_task)
+    def test_admin_can_create_config_on_other_apps(self):
+        """If a non-admin creates an app, an administrator should be able to set config
+        values for that app.
+        """
+        self.client.login(username='autotest2', password='password')
+        url = '/api/apps'
+        body = {'cluster': 'autotest'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        app_id = response.data['id']
+        self.client.login(username='autotest', password='password')
+        url = "/api/apps/{app_id}/config".format(**locals())
+        # set an initial config value
+        body = {'values': json.dumps({'PORT': '5000'})}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        self.assertIn('PORT', json.loads(response.data['values']))
+
     @mock.patch('requests.post', mock_import_repository_task)
     def test_limit_memory(self):
         """
@@ -348,4 +367,3 @@ def test_tags(self):
         self.assertEqual(self.client.put(url).status_code, 405)
         self.assertEqual(self.client.patch(url).status_code, 405)
         self.assertEqual(self.client.delete(url).status_code, 405)
-        return tags4
diff --git a/controller/api/tests/test_container.py b/controller/api/tests/test_container.py
--- a/controller/api/tests/test_container.py
+++ b/controller/api/tests/test_container.py
@@ -400,3 +400,27 @@ def test_container_scale_errors(self):
         body = {'web': 1}
         response = self.client.post(url, json.dumps(body), content_type='application/json')
         self.assertEqual(response.status_code, 204)
+
+    def test_admin_can_manage_other_containers(self):
+        """If a non-admin user creates a container, an administrator should be able to
+        manage it.
+        """
+        self.client.login(username='autotest2', password='password')
+        url = '/api/apps'
+        body = {'cluster': 'autotest'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        app_id = response.data['id']
+        # post a new build
+        url = "/api/apps/{app_id}/builds".format(**locals())
+        body = {'image': 'autotest/example', 'sha': 'a'*40,
+                'procfile': json.dumps({'web': 'node server.js', 'worker': 'node worker.js'})}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        # login as admin
+        self.client.login(username='autotest', password='password')
+        # scale up
+        url = "/api/apps/{app_id}/scale".format(**locals())
+        body = {'web': 4, 'worker': 2}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 204)
diff --git a/controller/api/tests/test_domain.py b/controller/api/tests/test_domain.py
--- a/controller/api/tests/test_domain.py
+++ b/controller/api/tests/test_domain.py
@@ -89,8 +89,23 @@ def test_manage_domain_invalid_domain(self):
         self.assertEqual(response.status_code, 400)
 
     def test_manage_domain_wildcard(self):
-        # Wildcards are not allowed for now.
+        """Wildcards are not allowed for now."""
         url = '/api/apps/{app_id}/domains'.format(app_id=self.app_id)
         body = {'domain': '*.deis.example.com'}
         response = self.client.post(url, json.dumps(body), content_type='application/json')
         self.assertEqual(response.status_code, 400)
+
+    def test_admin_can_add_domains_to_other_apps(self):
+        """If a non-admin user creates an app, an administrator should be able to add
+        domains to it.
+        """
+        self.client.login(username='autotest2', password='password')
+        url = '/api/apps'
+        body = {'cluster': 'autotest'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        self.client.login(username='autotest', password='password')
+        url = '/api/apps/{}/domains'.format(self.app_id)
+        body = {'domain': 'example.deis.example.com'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
diff --git a/controller/api/tests/test_hooks.py b/controller/api/tests/test_hooks.py
--- a/controller/api/tests/test_hooks.py
+++ b/controller/api/tests/test_hooks.py
@@ -251,3 +251,30 @@ def test_config_hook(self):
         self.assertEqual(response.status_code, 200)
         self.assertIn('values', response.data)
         self.assertEqual(values, response.data['values'])
+
+    def test_admin_can_hook(self):
+        """Administrator should be able to create build hooks on non-admin apps.
+        """
+        """Test creating a Push via the API"""
+        self.client.login(username='autotest2', password='password')
+        url = '/api/apps'
+        body = {'cluster': 'autotest'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        app_id = response.data['id']
+        self.client.login(username='autotest', password='password')
+        # prepare a push body
+        DOCKERFILE = """
+        FROM busybox
+        CMD /bin/true
+        """
+        body = {'receive_user': 'autotest',
+                'receive_repo': app_id,
+                'image': '{app_id}:v2'.format(**locals()),
+                'sha': 'ecdff91c57a0b9ab82e89634df87e293d259a3aa',
+                'dockerfile': DOCKERFILE}
+        url = '/api/hooks/builds'
+        response = self.client.post(url, json.dumps(body), content_type='application/json',
+                                    HTTP_X_DEIS_BUILDER_AUTH=settings.BUILDER_KEY)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data['release']['version'], 2)
diff --git a/controller/api/tests/test_perm.py b/controller/api/tests/test_perm.py
--- a/controller/api/tests/test_perm.py
+++ b/controller/api/tests/test_perm.py
@@ -144,16 +144,16 @@ def setUp(self):
             self.client.login(username='autotest-1', password='password'))
 
     def test_create(self):
-        # check that user 1 sees her lone app
+        # check that user 1 sees her lone app and user 2's app
         response = self.client.get('/api/apps')
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(len(response.data['results']), 1)
+        self.assertEqual(len(response.data['results']), 2)
         app_id = response.data['results'][0]['id']
-        # check that user 2 can't see any apps
+        # check that user 2 can only see his app
         self.assertTrue(
             self.client.login(username='autotest-2', password='password'))
         response = self.client.get('/api/apps')
-        self.assertEqual(len(response.data['results']), 0)
+        self.assertEqual(len(response.data['results']), 1)
         # check that user 2 can't see any of the app's builds, configs,
         # containers, limits, or releases
         for model in ['builds', 'config', 'containers', 'limits', 'releases']:
@@ -172,7 +172,7 @@ def test_create(self):
             self.client.login(username='autotest-2', password='password'))
         response = self.client.get('/api/apps')
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(len(response.data['results']), 1)
+        self.assertEqual(len(response.data['results']), 2)
         # check that user 2 sees (empty) results now for builds, containers,
         # and releases. (config and limit will still give 404s since we didn't
         # push a build here.)
@@ -203,12 +203,12 @@ def test_delete(self):
         body = {'username': 'autotest-2'}
         response = self.client.post(url, json.dumps(body), content_type='application/json')
         self.assertEqual(response.status_code, 201)
-        # check that user 2 can see the app
+        # check that user 2 can see the app as well as his own
         self.assertTrue(
             self.client.login(username='autotest-2', password='password'))
         response = self.client.get('/api/apps')
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(len(response.data['results']), 1)
+        self.assertEqual(len(response.data['results']), 2)
         # try to delete the permission as user 2
         url = "/api/apps/{}/perms/{}".format(app_id, 'autotest-2')
         response = self.client.delete(url, content_type='application/json')
@@ -220,11 +220,11 @@ def test_delete(self):
         response = self.client.delete(url, content_type='application/json')
         self.assertEqual(response.status_code, 204)
         self.assertIsNone(response.data)
-        # check that user 2 can't see any apps
+        # check that user 2 can only see his app
         self.assertTrue(
             self.client.login(username='autotest-2', password='password'))
         response = self.client.get('/api/apps')
-        self.assertEqual(len(response.data['results']), 0)
+        self.assertEqual(len(response.data['results']), 1)
         # delete permission to user 1's app again, expecting an error
         self.assertTrue(
             self.client.login(username='autotest-1', password='password'))
@@ -232,10 +232,10 @@ def test_delete(self):
         self.assertEqual(response.status_code, 404)
 
     def test_list(self):
-        # check that user 1 sees her lone app
+        # check that user 1 sees her lone app and user 2's app
         response = self.client.get('/api/apps')
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(len(response.data['results']), 1)
+        self.assertEqual(len(response.data['results']), 2)
         app_id = response.data['results'][0]['id']
         # create a new object permission
         url = "/api/apps/{}/perms".format(app_id)
@@ -247,6 +247,12 @@ def test_list(self):
             "/api/apps/{}/perms".format(app_id), content_type='application/json')
         self.assertEqual(response.data, {'users': ['autotest-2']})
 
+    def test_admin_can_list(self):
+        """Check that an administrator can list an app's perms"""
+        response = self.client.get('/api/apps')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.data['results']), 2)
+
     def test_list_errors(self):
         response = self.client.get('/api/apps')
         app_id = response.data['results'][0]['id']
diff --git a/controller/api/tests/test_release.py b/controller/api/tests/test_release.py
--- a/controller/api/tests/test_release.py
+++ b/controller/api/tests/test_release.py
@@ -213,3 +213,27 @@ def test_release_summary(self):
         release = Release.objects.get(uuid=release3['uuid'])
         # check that the release has push and env change messages
         self.assertIn('autotest deployed ', release.summary)
+
+    @mock.patch('requests.post', mock_import_repository_task)
+    def test_admin_can_create_release(self):
+        """If a non-user creates an app, an admin should be able to create releases."""
+        self.client.login(username='autotest2', password='password')
+        url = '/api/apps'
+        body = {'cluster': 'autotest'}
+        response = self.client.post(url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        app_id = response.data['id']
+        self.client.login(username='autotest', password='password')
+        # check that updating config rolls a new release
+        url = '/api/apps/{app_id}/config'.format(**locals())
+        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        response = self.client.post(
+            url, json.dumps(body), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        self.assertIn('NEW_URL1', json.loads(response.data['values']))
+        # check to see that an initial release was created
+        url = '/api/apps/{app_id}/releases'.format(**locals())
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+        # account for the config release as well
+        self.assertEqual(response.data['count'], 2)