diff --git a/rootfs/api/consumers.py b/rootfs/api/consumers.py
index e1552043d..a04186050 100644
--- a/rootfs/api/consumers.py
+++ b/rootfs/api/consumers.py
@@ -20,7 +20,7 @@
 from channels.generic.websocket import AsyncWebsocketConsumer
 
 from .models.app import App
-from .permissions import has_app_permission
+from .permissions import has_object_permission
 
 
 class BaseAppConsumer(AsyncWebsocketConsumer):
@@ -35,7 +35,7 @@ def has_perm(self):
         if permission is None:
             try:
                 app = App.objects.get(id=self.id)
-                permission = has_app_permission(self.scope["user"], app, "GET")
+                permission = has_object_permission(self.scope["user"], app, "GET")
                 if permission[0]:
                     cache.set(key, permission, timeout=self.timeout)
             except App.DoesNotExist:
diff --git a/rootfs/api/migrations/0010_alter_certificate_options.py b/rootfs/api/migrations/0010_alter_certificate_options.py
new file mode 100644
index 000000000..fd5e60f4b
--- /dev/null
+++ b/rootfs/api/migrations/0010_alter_certificate_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.15 on 2024-08-14 09:43
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0009_remove_appsettings_canaries_remove_release_canary_and_more'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='certificate',
+            options={'ordering': ['name', 'common_name', 'expires'], 'permissions': (('use_cert', 'Can use cert'),)},
+        ),
+    ]
diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py
index 84ed5cf1e..515ce2d33 100644
--- a/rootfs/api/models/app.py
+++ b/rootfs/api/models/app.py
@@ -31,10 +31,12 @@
 from .tls import TLS
 from .appsettings import AppSettings
 from .volume import Volume
-from .base import UuidAuditedModel, PROCFILE_TYPE_WEB, PROCFILE_TYPE_RUN, DEFAULT_HTTP_PORT
+from .base import UuidAuditedModel, PROCFILE_TYPE_WEB, PROCFILE_TYPE_RUN, DEFAULT_HTTP_PORT, \
+    ObjectPolicy, object_policy_registry
 
 User = get_user_model()
 logger = logging.getLogger(__name__)
+app_policy = ObjectPolicy('id', 'use_app', 'Can use app')  # query field, policy, description
 
 
 # http://kubernetes.io/v1.1/docs/design/identifiers.html
@@ -77,7 +79,7 @@ class App(UuidAuditedModel):
 
     class Meta:
         verbose_name = 'Application'
-        permissions = (('use_app', 'Can use app'),)
+        permissions = (app_policy[1:], )
         ordering = ['id']
 
     def save(self, *args, **kwargs):
@@ -1161,3 +1163,7 @@ def _gather_app_settings(self, release, app_settings, procfile_type, replicas, v
             'pod_security_context': limit_plan.pod_security_context,
             'container_security_context': limit_plan.container_security_context,
         }
+
+
+# Register policy
+object_policy_registry.register(App, app_policy)
diff --git a/rootfs/api/models/base.py b/rootfs/api/models/base.py
index 87ae5353a..038de2970 100644
--- a/rootfs/api/models/base.py
+++ b/rootfs/api/models/base.py
@@ -4,6 +4,7 @@
 import importlib
 from datetime import timedelta
 from functools import partial
+from collections import namedtuple
 from django.db import models
 from django.conf import settings
 from django.utils import timezone
@@ -38,6 +39,38 @@
 def get_anonymous_user_instance(user): return user(id=-1, username=settings.ANONYMOUS_USER_NAME)
 
 
+ObjectPolicy = namedtuple('ObjectPolicy', ['unique', 'codename', 'description'])
+
+
+class ObjectPolicyRegistry(object):
+
+    def __init__(self):
+        self.object_permission_policy_table = dict[type, ObjectPolicy]()
+
+    def all(self) -> list[type, ObjectPolicy]:
+        return self.object_permission_policy_table.items()
+
+    def get(self, query) -> tuple[type, ObjectPolicy]:
+        if isinstance(query, str):
+            for key, value in self.object_permission_policy_table.items():
+                if value.codename == query:
+                    return key, value
+        elif isinstance(query, models.Model):
+            query = type(query)
+            value = self.object_permission_policy_table.get(query)
+            return query if value else None, value
+        elif isinstance(query, type):
+            value = self.object_permission_policy_table.get(query)
+            return query if value else None, value
+        return None, None
+
+    def register(self, cls: type, object_policy: ObjectPolicy) -> None:
+        self.object_permission_policy_table[cls] = object_policy
+
+
+object_policy_registry = ObjectPolicyRegistry()
+
+
 class AuditedModel(models.Model):
     """Add created and updated fields to a model."""
 
diff --git a/rootfs/api/models/certificate.py b/rootfs/api/models/certificate.py
index 45c473a99..7c002d3e3 100644
--- a/rootfs/api/models/certificate.py
+++ b/rootfs/api/models/certificate.py
@@ -15,11 +15,12 @@
 from api.utils import validate_label
 from api.exceptions import AlreadyExists, ServiceUnavailable
 from scheduler import KubeException
-from .base import AuditedModel
+from .base import AuditedModel, ObjectPolicy, object_policy_registry
 from .domain import Domain
 
 User = get_user_model()
 logger = logging.getLogger(__name__)
+cert_policy = ObjectPolicy('name', 'use_cert', 'Can use cert')
 
 
 # Note: This is a slightly bug-fixed version of same from ndg-httpsclient.
@@ -120,6 +121,7 @@ class Certificate(AuditedModel):
     subject = models.TextField(editable=False)
 
     class Meta:
+        permissions = (cert_policy[1:], )
         ordering = ['name', 'common_name', 'expires']
 
     @property
@@ -238,3 +240,7 @@ def detach(self, *args, **kwargs):
                 raise ServiceUnavailable(
                     "Could not delete certificate secret {} for application {}".format(
                         self.certname, namespace)) from e
+
+
+# Register policy
+object_policy_registry.register(Certificate, cert_policy)
diff --git a/rootfs/api/permissions.py b/rootfs/api/permissions.py
index f7fcab261..8ea9444ea 100644
--- a/rootfs/api/permissions.py
+++ b/rootfs/api/permissions.py
@@ -3,13 +3,15 @@
 from django.conf import settings
 from django.contrib.auth.models import AnonymousUser
 from api import manager
-from api.models.blocklist import Blocklist, App
+from api.models import app
+from api.models import blocklist
+from api.models.base import object_policy_registry
 
 
 def get_app_status(app):
-    blocklist = Blocklist.get_blocklist(app)
-    if blocklist:
-        return False, blocklist.remark
+    block = blocklist.Blocklist.get_blocklist(app)
+    if block:
+        return False, block.remark
     if settings.WORKFLOW_MANAGER_URL:
         status = manager.User().get_status(app.owner.pk)
         if not status["is_active"]:
@@ -17,23 +19,22 @@ def get_app_status(app):
     return True, None
 
 
-def has_app_permission(user, obj, method):
-    if isinstance(obj, App) or hasattr(obj, 'app'):
-        app = obj if isinstance(obj, App) else obj.app
-        is_ok, message = get_app_status(app)
-        if is_ok:
-            if user.is_superuser:
-                return True, None
-            elif app.owner == user:
-                return True, None
-            elif user.is_staff or user.has_perm('use_app', app):
-                if method != 'DELETE':
-                    return True, None
-                else:
-                    return False, "User does not have permission to delete"
+def has_object_permission(user, obj, method):
+    obj = getattr(obj, 'app', obj)
+    object_policy = object_policy_registry.get(obj)[1]
+    has_permission, message = False, f"{obj} object does not exist or does not have permission."
+    if user.is_superuser:
+        has_permission, message = True, None
+    elif getattr(obj, "owner", None) == user:
+        has_permission, message = True, None
+    elif user.is_staff or (object_policy and user.has_perm(object_policy.codename, obj)):
+        if method != 'DELETE':
+            has_permission, message = True, None
         else:
-            return is_ok, message
-    return False, "App object does not exist or does not have permission."
+            has_permission, message = False, "{user} does not have permission to delete."
+    elif has_permission and isinstance(obj, app.App):
+        return get_app_status(obj)
+    return has_permission, message
 
 
 class IsAnonymous(permissions.BasePermission):
@@ -75,13 +76,13 @@ def has_object_permission(self, request, view, obj):
             return False
 
 
-class IsAppUser(permissions.BasePermission):
+class IsObjectUser(permissions.BasePermission):
     """
     Object-level permission to allow owners or collaborators to access
     an app-related model.
     """
     def has_object_permission(self, request, view, obj):
-        return has_app_permission(request.user, obj, request.method)[0]
+        return has_object_permission(request.user, obj, request.method)[0]
 
 
 class IsAdmin(permissions.BasePermission):
diff --git a/rootfs/api/tests/test_app.py b/rootfs/api/tests/test_app.py
index 80adf01dc..25b81da22 100644
--- a/rootfs/api/tests/test_app.py
+++ b/rootfs/api/tests/test_app.py
@@ -22,6 +22,7 @@
 
 from api.exceptions import DryccException
 from api.tests import adapter, DryccTestCase
+from api.models.base import object_policy_registry
 import requests_mock
 
 User = get_user_model()
@@ -358,9 +359,12 @@ def test_app_transfer(self, mock_requests):
         self.assertEqual(config2.owner, collaborator)
 
         # Collaborators can't transfer
-        body = {'username': owner.username}
-        perms_url = url+"/perms/"
-        response = self.client.post(perms_url, body)
+        body = {
+            'username': owner.username,
+            'codename': object_policy_registry.get(App)[1].codename,
+            'uniqueid': app.id,
+        }
+        response = self.client.post('/v2/perms/rules/', body)
         self.assertEqual(response.status_code, 201, response.data)
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token)
diff --git a/rootfs/api/tests/test_hooks.py b/rootfs/api/tests/test_hooks.py
index 432c46a99..81923dd2d 100644
--- a/rootfs/api/tests/test_hooks.py
+++ b/rootfs/api/tests/test_hooks.py
@@ -8,6 +8,8 @@
 from django.core.cache import cache
 
 from api.tests import adapter, DryccTransactionTestCase
+from api.models.app import App
+from api.models.base import object_policy_registry
 import requests_mock
 
 User = get_user_model()
@@ -70,8 +72,12 @@ def test_key_hook(self, mock_requests):
         app_id = self.create_app()
 
         # give user permission to app
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': str(self.user)}
+        body = {
+            'username': str(self.user),
+            'codename': object_policy_registry.get(App)[1].codename,
+            'uniqueid': app_id,
+        }
+        url = "/v2/perms/rules"
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
diff --git a/rootfs/api/tests/test_perm.py b/rootfs/api/tests/test_perm.py
index 2969859c0..80cf8206f 100644
--- a/rootfs/api/tests/test_perm.py
+++ b/rootfs/api/tests/test_perm.py
@@ -1,11 +1,13 @@
 from django.test import tag
 from django.contrib.auth import get_user_model
 from api.tests import DryccTestCase
+from api.models.base import object_policy_registry
+from api.models.app import App
 
 User = get_user_model()
 
 
-class TestAppPerms(DryccTestCase):
+class TestUserPerm(DryccTestCase):
 
     fixtures = ['test_sharing.json']
 
@@ -21,6 +23,18 @@ def setUp(self):
         self.user3 = User.objects.get(username='autotest-3')
         self.token3 = self.get_or_create_token(self.user3)
 
+    @tag('auth')
+    def test_codes(self):
+        response = self.client.get('/v2/perms/codes/')
+        expect = {
+            'results': [
+                {'codename': 'use_app', 'description': 'Can use app'},
+                {'codename': 'use_cert', 'description': 'Can use cert'}
+            ],
+            'count': 2
+        }
+        self.assertEqual(expect, response.json())
+
     @tag('auth')
     def test_create(self):
         # check that user 1 sees her lone app and user 2's app
@@ -43,13 +57,19 @@ def test_create(self):
             self.assertEqual(response.data['detail'],
                              'You do not have permission to perform this action.', msg=msg)
 
-        # TODO: test that git pushing to the app fails
-        # give user 2 permission to user 1's app
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': 'autotest-2'}
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
+        url = "/v2/perms/rules/"
+        for cls, object_policy in object_policy_registry.all():
+            if not cls.objects.exists():
+                continue
+            for obj in cls.objects.filter(owner=self.user):
+                body = {
+                    'username': self.user2.username,
+                    'codename': object_policy.codename,
+                    'uniqueid': getattr(obj, object_policy.unique)
+                }
+                self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+                response = self.client.post(url, body)
+                self.assertEqual(response.status_code, 201, response.data)
 
         # check that user 2 can see the app
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
@@ -71,11 +91,15 @@ def test_create_errors(self):
         response = self.client.get('/v2/apps')
         app_id = response.data['results'][0]['id']
 
+        body = {
+            'username': self.user2.username,
+            'codename': object_policy_registry.get(App)[1].codename,
+            'uniqueid': app_id,
+        }
+
         # check that user 2 can't create a permission
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': 'autotest-2'}
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.post(url, body)
+        response = self.client.post("/v2/perms/rules/", body)
         self.assertEqual(response.status_code, 403)
 
     @tag('auth')
@@ -83,9 +107,12 @@ def test_delete(self):
         # give user 2 permission to user 1's app
         response = self.client.get('/v2/apps')
         app_id = response.data['results'][0]['id']
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': 'autotest-2'}
-        response = self.client.post(url, body)
+        body = {
+            'username': self.user2.username,
+            'codename': object_policy_registry.get(App)[1].codename,
+            'uniqueid': app_id,
+        }
+        response = self.client.post("/v2/perms/rules/", body)
         self.assertEqual(response.status_code, 201, response.data)
 
         # check that user 2 can see the app as well as his own
@@ -96,7 +123,8 @@ def test_delete(self):
 
         # delete permission to user 1's app
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = "/v2/apps/{}/perms/{}".format(app_id, 'autotest-2')
+        response = self.client.get("/v2/perms/rules/")
+        url = "/v2/perms/rules/{}/".format(response.json()["results"][0]["id"])
         response = self.client.delete(url)
         self.assertEqual(response.status_code, 204, response.data)
         self.assertIsNone(response.data)
@@ -109,7 +137,7 @@ def test_delete(self):
         # delete permission to user 1's app again, expecting an error
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         response = self.client.delete(url)
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 404)
 
     @tag('auth')
     def test_list(self):
@@ -120,14 +148,17 @@ def test_list(self):
         app_id = response.data['results'][0]['id']
 
         # create a new object permission
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': 'autotest-2'}
-        response = self.client.post(url, body)
+        body = {
+            'username': self.user2.username,
+            'codename': object_policy_registry.get(App)[1].codename,
+            'uniqueid': app_id,
+        }
+        response = self.client.post("/v2/perms/rules/", body)
         self.assertEqual(response.status_code, 201, response.data)
 
         # list perms on the app
-        response = self.client.get("/v2/apps/{}/perms".format(app_id))
-        self.assertEqual(response.data, {'users': ['autotest-2']})
+        response = self.client.get("/v2/perms/rules/")
+        self.assertEqual(response.data["count"], 1)
 
     @tag('auth')
     def test_admin_can_list(self):
@@ -139,12 +170,11 @@ def test_admin_can_list(self):
     @tag('auth')
     def test_list_errors(self):
         response = self.client.get('/v2/apps')
-        app_id = response.data['results'][0]['id']
-
         # login as user 2, list perms on the app
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get("/v2/apps/{}/perms".format(app_id))
-        self.assertEqual(response.status_code, 403)
+        response = self.client.get("/v2/perms/rules/")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data, {'results': [], 'count': 0})
 
     @tag('auth')
     def test_unauthorized_user_cannot_modify_perms(self):
@@ -159,10 +189,13 @@ def test_unauthorized_user_cannot_modify_perms(self):
         body = {'id': app_id}
         response = self.client.post(url, body)
 
-        url = '{}/{}/perms'.format(url, app_id)
-        body = {'username': self.user2.username}
+        body = {
+            'username': self.user2.username,
+            'codename': object_policy_registry.get(App)[1].codename,
+            'uniqueid': app_id,
+        }
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.post(url, body)
+        response = self.client.post('/v2/perms/rules/', body)
         self.assertEqual(response.status_code, 403)
 
     @tag('auth')
@@ -174,17 +207,25 @@ def test_collaborator_cannot_share(self):
         owner_token = self.token
         collab = self.user2
         collab_token = self.token2
-        url = '/v2/apps/{}/perms'.format(app_id)
 
         # Share app with collaborator
-        body = {'username': collab.username}
+        body = {
+            'username': collab.username,
+            'codename': object_policy_registry.get(App)[1].codename,
+            'uniqueid': app_id,
+        }
+        url = '/v2/perms/rules/'
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token)
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
         # Collaborator should fail to share app
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token)
-        body = {'username': self.user3.username}
+        body = {
+            'username': self.user3.username,
+            'codename': object_policy_registry.get(App)[1].codename,
+            'uniqueid': app_id,
+        }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 403)
 
@@ -197,16 +238,18 @@ def test_collaborator_cannot_share(self):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token3)
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(response.data['count'], 1, response.data)
 
         # Collaborator cannot delete other collaborator
-        url += "/{}".format(self.user3.username)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token)
+        url += "{}/".format(response.data["results"][0]["id"])
         response = self.client.delete(url)
         self.assertEqual(response.status_code, 403)
 
         # Collaborator can delete themselves
-        url = '/v2/apps/{}/perms/{}'.format(app_id, collab.username)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token3)
         response = self.client.delete(url)
-        self.assertEqual(response.status_code, 204, response.data)
+        self.assertEqual(response.status_code, 204)
diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py
index 69fd4b291..312aecf74 100644
--- a/rootfs/api/urls.py
+++ b/rootfs/api/urls.py
@@ -97,13 +97,6 @@
     re_path(
         r"^apps/(?P<id>{})/tls/?$".format(settings.APP_URL_REGEX),
         views.TLSViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
-    # apps sharing
-    re_path(
-        r"^apps/(?P<id>{})/perms/(?P<username>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
-        views.AppPermsViewSet.as_view({'delete': 'destroy'})),
-    re_path(
-        r"^apps/(?P<id>{})/perms/?$".format(settings.APP_URL_REGEX),
-        views.AppPermsViewSet.as_view({'get': 'list', 'post': 'create'})),
     # application volumes
     re_path(
         r"^apps/(?P<id>{})/volumes/?$".format(settings.APP_URL_REGEX),
@@ -173,13 +166,6 @@
     re_path(
         r'^auth/whoami/?$',
         views.UserManagementViewSet.as_view({'get': 'list'})),
-    # admin sharing
-    re_path(
-        r'^admin/perms/(?P<username>[\w.@+-]+)/?$',
-        views.AdminPermsViewSet.as_view({'delete': 'destroy'})),
-    re_path(
-        r'^admin/perms/?$',
-        views.AdminPermsViewSet.as_view({'get': 'list', 'post': 'create'})),
     # certificates
     re_path(
         r'^certs/(?P<name>[-_*.\w]+)/domain/(?P<domain>\**\.?[-\._\w]+)?/?$',
@@ -233,6 +219,12 @@
     re_path(
         r'^manager/(?P<type>[\w.@+-]+)s/(?P<id>{})/unblock/?$'.format(settings.APP_URL_REGEX),
         views.WorkflowManagerViewset.as_view({'delete': 'unblock'})),
+    # user perms
+    re_path(r"^perms/codes/?$", views.UserPermViewSet.as_view({'get': 'codes'})),
+    re_path(
+        r"^perms/rules/?$", views.UserPermViewSet.as_view({'get': 'list', 'post': 'create'})),
+    re_path(
+        r"^perms/rules/(?P<id>[-_\w]+)/?$", views.UserPermViewSet.as_view({'delete': 'destroy'})),
     # tokens
     re_path(r'^tokens/?$', views.TokenViewSet.as_view({'get': 'list'})),
     re_path(r"^tokens/(?P<pk>[-_\w]+)/?$", views.TokenViewSet.as_view({'delete': 'destroy'})),
diff --git a/rootfs/api/views.py b/rootfs/api/views.py
index 96a3cc1c8..5bebce5ce 100644
--- a/rootfs/api/views.py
+++ b/rootfs/api/views.py
@@ -13,13 +13,12 @@
 from django.contrib.auth import get_user_model
 from django.shortcuts import get_object_or_404, redirect
 from guardian.shortcuts import assign_perm, get_objects_for_user, \
-    get_users_with_perms, remove_perm
+    get_users_with_perms, remove_perm, UserObjectPermission
 from django.views.generic import View
 from django.utils.decorators import method_decorator
 from django.views.decorators.cache import cache_page
 from django.views.decorators.vary import vary_on_headers
 from rest_framework import renderers, status, filters
-from rest_framework.exceptions import PermissionDenied, NotFound
 from rest_framework.permissions import IsAuthenticated, AllowAny
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
@@ -244,7 +243,7 @@ class BaseDryccViewSet(viewsets.OwnerViewSet):
     the `model` attribute shortcut.
     """
     lookup_field = 'id'
-    permission_classes = [IsAuthenticated, permissions.IsAppUser]
+    permission_classes = [IsAuthenticated, permissions.IsObjectUser]
     renderer_classes = [renderers.JSONRenderer]
 
 
@@ -295,7 +294,7 @@ def list(self, request, *args, **kwargs):
         interact with.
         """
         queryset = super(AppViewSet, self).get_queryset(**kwargs) | \
-            get_objects_for_user(self.request.user, 'api.use_app')
+            get_objects_for_user(self.request.user, f'api.{models.app.app_policy[1]}')
         instance = self.filter_queryset(queryset)
         page = self.paginate_queryset(instance)
         if page is not None:
@@ -327,7 +326,7 @@ def update(self, request, **kwargs):
 
         if request.data.get('owner'):
             if self.request.user != app.owner and not self.request.user.is_superuser:
-                raise PermissionDenied()
+                return Response(status=status.HTTP_403_FORBIDDEN)
             new_owner = get_object_or_404(User, username=request.data['owner'])
             # ensure all downstream objects that are owned by this user and are part of this app
             # is also updated
@@ -575,6 +574,11 @@ class CertificateViewSet(BaseDryccViewSet):
     model = models.certificate.Certificate
     serializer_class = serializers.CertificateSerializer
 
+    def get_queryset(self, **kwargs):
+        return self.model.objects.filter(owner=self.request.user, **kwargs) | \
+            get_objects_for_user(
+                self.request.user, f'api.{models.certificate.cert_policy[1]}')
+
     def get_object(self, **kwargs):
         """Retrieve domain certificate by its name"""
         qs = self.get_queryset(**kwargs)
@@ -598,7 +602,13 @@ def detach(self, request, *args, **kwargs):
             self.get_object().detach(*args, **kwargs)
         except Http404:
             raise
+        return Response(status=status.HTTP_204_NO_CONTENT)
 
+    def destroy(self, *args, **kwargs):
+        resource = self.get_object()
+        if self.request.user != resource.owner and not self.request.user.is_superuser:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        resource.delete()
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
@@ -653,7 +663,7 @@ def public_key(self, request, *args, **kwargs):
         key = get_object_or_404(models.key.Key, fingerprint=fingerprint)
 
         queryset = models.app.App.objects.all() | \
-            get_objects_for_user(self.request.user, 'api.use_app')
+            get_objects_for_user(self.request.user, f'api.{models.app.app_policy[1]}')
         items = self.filter_queryset(queryset)
 
         apps = []
@@ -669,10 +679,8 @@ def public_key(self, request, *args, **kwargs):
 
     def app(self, request, *args, **kwargs):
         app = get_object_or_404(models.app.App, id=kwargs['id'])
-
-        perm_name = "api.use_app"
         usernames = [u.id for u in get_users_with_perms(app)
-                     if u.has_perm(perm_name, app)]
+                     if u.has_perm(f"api.{models.app.app_policy[1]}", app)]
 
         data = {}
         result = models.key.Key.objects \
@@ -695,10 +703,10 @@ def users(self, request, *args, **kwargs):
         app = get_object_or_404(models.app.App, id=kwargs['id'])
         request.user = get_object_or_404(User, username=kwargs['username'])
         # check the user is authorized for this app
-        has_permission, message = permissions.has_app_permission(
+        has_permission, message = permissions.has_object_permission(
             request.user, app, request.method)
         if not has_permission:
-            raise PermissionDenied(message)
+            return Response(message, status=status.HTTP_403_FORBIDDEN)
 
         data = {request.user.username: []}
         keys = models.key.Key.objects \
@@ -706,7 +714,7 @@ def users(self, request, *args, **kwargs):
                      .values('public', 'fingerprint') \
                      .order_by('created')
         if not keys:
-            raise NotFound("No Keys match the given query.")
+            return Response("No Keys match the given query.", status=status.HTTP_404_NOT_FOUND)
 
         for info in keys:
             data[request.user.username].append({
@@ -726,9 +734,9 @@ def create(self, request, *args, **kwargs):
         app = get_object_or_404(models.app.App, id=request.data['receive_repo'])
         self.user = request.user = get_object_or_404(User, username=request.data['receive_user'])
         # check the user is authorized for this app
-        has_permission, message = permissions.has_app_permission(self.user, app, request.method)
+        has_permission, message = permissions.has_object_permission(self.user, app, request.method)
         if not has_permission:
-            raise PermissionDenied(message)
+            return Response(message, status=status.HTTP_403_FORBIDDEN)
         request.data['app'] = app
         request.data['owner'] = self.user
         super(BuildHookViewSet, self).create(request, *args, **kwargs)
@@ -753,56 +761,79 @@ def create(self, request, *args, **kwargs):
         app = get_object_or_404(models.app.App, id=request.data['receive_repo'])
         request.user = get_object_or_404(User, username=request.data['receive_user'])
         # check the user is authorized for this app
-        has_permission, message = permissions.has_app_permission(
+        has_permission, message = permissions.has_object_permission(
             request.user, app, request.method)
         if not has_permission:
-            raise PermissionDenied(message)
+            return Response(message, status=status.HTTP_403_FORBIDDEN)
         config = app.release_set.filter(failed=False).latest().config
         serializer = self.get_serializer(config)
         return Response(serializer.data, status=status.HTTP_200_OK)
 
 
-class AppPermsViewSet(BaseDryccViewSet):
+class UserPermViewSet(BaseDryccViewSet):
     """RESTful views for sharing apps with collaborators."""
 
-    model = models.app.App  # models class
-    perm = 'use_app'    # short name for permission
-
-    def get_queryset(self):
-        return self.model.objects.all()
+    def codes(self, request, **kwargs):
+        results = []
+        for _, object_policy in permissions.object_policy_registry.all():
+            results.append({
+                "codename": object_policy.codename,
+                "description": object_policy.description,
+            })
+        # fake out pagination for now
+        pagination = {'results': results, 'count': len(results)}
+        return Response(data=pagination)
 
     def list(self, request, **kwargs):
-        app = self.get_object()
-        perm_name = "api.{}".format(self.perm)
-        usernames = [u.username for u in get_users_with_perms(app)
-                     if u.has_perm(perm_name, app)]
-        return Response({'users': usernames})
+        cls, object_policy = permissions.object_policy_registry.get(
+            request.query_params.get('codename', ''))
+        if object_policy:
+            object_policies = [(cls, object_policy), ]
+        else:
+            object_policies = permissions.object_policy_registry.all()
+        results = []
+        for cls, object_policy in object_policies:
+            user_object_perms = UserObjectPermission.objects.filter(
+                object_pk__in=[obj.pk for obj in cls.objects.filter(owner=request.user)],
+                permission__codename=object_policy.codename
+            )
+            user_object_perms |= UserObjectPermission.objects.filter(
+                user=request.user, permission__codename=object_policy.codename)
+
+            for user_object_perm in user_object_perms:
+                results.append({
+                    "id": user_object_perm.pk,
+                    "codename": user_object_perm.permission.codename,
+                    "uniqueid": getattr(user_object_perm.content_object, object_policy.unique),
+                    "username": user_object_perm.user.username,
+                })
+        # fake out pagination for now
+        pagination = {'results': results, 'count': len(results)}
+        return Response(data=pagination)
 
     def create(self, request, **kwargs):
-        app = self.get_object()
-        if not permissions.IsOwnerOrAdmin.has_object_permission(permissions.IsOwnerOrAdmin(),
-                                                                request, self, app):
-            raise PermissionDenied()
-
-        user = get_object_or_404(User, username=request.data['username'])
-        assign_perm(self.perm, user, app)
-        app.log("User {} was granted access to {}".format(user, app))
+        username = request.data.get('username')
+        codename = request.data.get('codename')
+        uniqueid = request.data.get('uniqueid')
+        cls, object_policy = permissions.object_policy_registry.get(codename)
+        if not cls or not object_policy:
+            return Response(f"User {codename} not found", status=status.HTTP_404_NOT_FOUND)
+        obj = get_object_or_404(cls, **{object_policy.unique: uniqueid})
+        if not permissions.IsOwnerOrAdmin().has_object_permission(request, self, obj):
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        user = get_object_or_404(User, username=username)
+        assign_perm(object_policy.codename, user, obj)
+        getattr(obj, "log", logger.info)("User {} was granted access to {}".format(user, obj))
         return Response(status=status.HTTP_201_CREATED)
 
     def destroy(self, request, **kwargs):
-        app = get_object_or_404(models.app.App, id=self.kwargs['id'])
-        user = get_object_or_404(User, username=kwargs['username'])
-
-        perm_name = "api.{}".format(self.perm)
-        if not user.has_perm(perm_name, app):
-            raise PermissionDenied()
-
-        if (user != request.user and
-            not permissions.IsOwnerOrAdmin.has_object_permission(permissions.IsOwnerOrAdmin(),
-                                                                 request, self, app)):
-            raise PermissionDenied()
-        remove_perm(self.perm, user, app)
-        app.log("User {} was revoked access to {}".format(user, app))
+        user_object_permission = get_object_or_404(UserObjectPermission, id=self.kwargs['id'])
+        obj = user_object_permission.content_object
+        if not (obj.owner == request.user or user_object_permission.user == request.user):
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        perm_name = f"api.{user_object_permission.permission.codename}"
+        remove_perm(perm_name, user_object_permission.user, obj)
+        obj.log("User {} was revoked access to {}".format(user_object_permission.user, obj))
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
@@ -975,7 +1006,7 @@ def binding(self, request, *args, **kwargs):
             logger.info("resoruce unbind response data: {}".format(serializer))
             return Response(serializer.data)
         else:
-            return Http404("unknown action")
+            return Response("unknown action", status=status.HTTP_404_NOT_FOUND)
 
 
 class GatewayViewSet(AppResourceViewSet):
@@ -1098,30 +1129,6 @@ def delete(self, request, *args, **kwargs):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
-class AdminPermsViewSet(BaseDryccViewSet):
-    """RESTful views for sharing admin permissions with other users."""
-
-    model = User
-    serializer_class = serializers.AdminUserSerializer
-    permission_classes = [permissions.IsAdmin]
-
-    def get_queryset(self, **kwargs):
-        self.check_object_permissions(self.request, self.request.user)
-        return self.model.objects.filter(is_active=True, is_superuser=True)
-
-    def create(self, request, **kwargs):
-        user = get_object_or_404(self.model, username=request.data['username'])
-        user.is_superuser = user.is_staff = True
-        user.save(update_fields=['is_superuser', 'is_staff'])
-        return Response(status=status.HTTP_201_CREATED)
-
-    def destroy(self, request, **kwargs):
-        user = get_object_or_404(self.model, username=kwargs['username'])
-        user.is_superuser = user.is_staff = False
-        user.save(update_fields=['is_superuser', 'is_staff'])
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-
 class UserView(BaseDryccViewSet):
     """A Viewset for interacting with User objects."""
     model = User
