diff --git a/api/permissions.py b/api/permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..fdb89abcbb3d84f9878534bfd435c25df965cf9b
--- /dev/null
+++ b/api/permissions.py
@@ -0,0 +1,43 @@
+from rest_framework import permissions
+
+from datasources import models
+
+
+class BaseUserPermission(permissions.BasePermission):
+    message = 'You do not have permission to access this resource.'
+    permission_level = models.UserPermissionLevels.NONE
+
+    def has_object_permission(self, request, view, obj):
+        if not obj.access_control:
+            return True
+
+        try:
+            permission = models.UserPermissionLink.objects.get(
+                user=request.user,
+                datasource=obj
+            )
+
+            return permission.granted >= self.permission_level
+
+        except models.UserPermissionLink.DoesNotExist:
+            return False
+
+
+class ViewPermission(BaseUserPermission):
+    message = 'You do not have permission to access this resource.'
+    permission_level = models.UserPermissionLevels.VIEW
+
+
+class MetadataPermission(BaseUserPermission):
+    message = 'You do not have permission to access the metadata of this resource.'
+    permission_level = models.UserPermissionLevels.META
+
+
+class DataPermission(BaseUserPermission):
+    message = 'You do not have permission to access the data of this resource.'
+    permission_level = models.UserPermissionLevels.DATA
+
+
+class ProvPermission(BaseUserPermission):
+    message = 'You do not have permission to access the prov data of this resource.'
+    permission_level = models.UserPermissionLevels.PROV
diff --git a/api/tests.py b/api/tests.py
index 19521951d376e8bc62885e143e07407fb0d666f2..e7cb105768785b577fc4603c493a4adf5b9d67e2 100644
--- a/api/tests.py
+++ b/api/tests.py
@@ -149,6 +149,191 @@ class DataSourceApiTest(TestCase):
         self._assert_datasource_correct(datasource)
 
 
+class DataSourceApiPermissionsTest(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = get_user_model().objects.create_user('Test API User')
+        cls.owner = get_user_model().objects.create_user('Test API Owner')
+
+    def setUp(self):
+        self.client = APIClient()
+        self.client.force_authenticate(self.user)
+
+        self.owner_client = APIClient()
+        self.owner_client.force_authenticate(self.owner)
+
+        self.test_name = 'Permissions'
+        # TODO don't rely on external URL for testing
+        self.test_url = 'https://api.iotuk.org.uk/iotOrganisation'
+
+    def tearDown(self):
+        try:
+            self.model.delete()
+        except AttributeError:
+            pass
+
+    def _grant_permission(self, level: models.UserPermissionLevels):
+        response = self.owner_client.post('/datasources/{}/access/grant'.format(self.model.pk),
+                                          data={
+                                              'user': self.user.pk,
+                                              'granted': level.value,
+                                          },
+                                          headers={
+                                              'Accept': 'application/json'
+                                          })
+        # TODO make this return a proper response code for AJAX-like requests
+        self.assertEqual(response.status_code, 302)
+
+    def test_datasource_permission_view(self):
+        """
+        Test that permissions are correctly handled when attempting to view a data source.
+        """
+        self.model = models.DataSource.objects.create(
+            name=self.test_name,
+            owner=self.owner,
+            url=self.test_url,
+            plugin_name='DataSetConnector',
+            access_control=True
+        )
+
+        url = '/api/datasources/{}/'.format(self.model.pk)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.VIEW)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self._grant_permission(models.UserPermissionLevels.META)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self._grant_permission(models.UserPermissionLevels.DATA)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self._grant_permission(models.UserPermissionLevels.PROV)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+    def test_datasource_permission_meta(self):
+        """
+        Test that permissions are correctly handled when attempting to get metadata from a data source.
+        """
+        self.model = models.DataSource.objects.create(
+            name=self.test_name,
+            owner=self.owner,
+            url=self.test_url,
+            plugin_name='DataSetConnector',
+            access_control=True
+        )
+
+        url = '/api/datasources/{}/metadata/'.format(self.model.pk)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.VIEW)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.META)
+
+        response = self.client.get(url)
+        # This data connector does not provide metadata
+        self.assertEqual(response.status_code, 400)
+
+        self._grant_permission(models.UserPermissionLevels.DATA)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 400)
+
+        self._grant_permission(models.UserPermissionLevels.PROV)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 400)
+
+    def test_datasource_permission_data(self):
+        """
+        Test that permissions are correctly handled when attempting to get data from a data source.
+        """
+        self.model = models.DataSource.objects.create(
+            name=self.test_name,
+            owner=self.owner,
+            url=self.test_url,
+            plugin_name='DataSetConnector',
+            access_control=True
+        )
+
+        url = '/api/datasources/{}/data/?year=2018'.format(self.model.pk)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.VIEW)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.META)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.DATA)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self._grant_permission(models.UserPermissionLevels.PROV)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+    def test_datasource_permission_prov(self):
+        """
+        Test that permissions are correctly handled when attempting to get PROV data from a data source.
+        """
+        self.model = models.DataSource.objects.create(
+            name=self.test_name,
+            owner=self.owner,
+            url=self.test_url,
+            plugin_name='DataSetConnector',
+            access_control=True
+        )
+
+        url = '/api/datasources/{}/prov/'.format(self.model.pk)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.VIEW)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.META)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.DATA)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 403)
+
+        self._grant_permission(models.UserPermissionLevels.PROV)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+
 class DataSourceApiIoTUKTest(TestCase):
     @classmethod
     def setUpTestData(cls):
diff --git a/api/views/datasources.py b/api/views/datasources.py
index 6040353bf4d8368581e9ce029ec3811ebd4b5ce3..1c866d4878fd333ea71f2c5b6d20e55744bee4be 100644
--- a/api/views/datasources.py
+++ b/api/views/datasources.py
@@ -5,6 +5,7 @@ from django.db.models import ObjectDoesNotExist
 from django.http import HttpResponse
 from rest_framework import decorators, response, viewsets
 
+from .. import permissions
 from datasources import models, serializers
 from provenance import models as prov_models
 
@@ -39,6 +40,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet):
     """
     queryset = models.DataSource.objects.all()
     serializer_class = serializers.DataSourceSerializer
+    permission_classes = [permissions.ViewPermission]
 
     def try_passthrough_response(self,
                                  map_response: typing.Callable[..., HttpResponse],
@@ -85,7 +87,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet):
             }
             return response.Response(data, status=400)
 
-    @decorators.action(detail=True)
+    @decorators.action(detail=True, permission_classes=[permissions.ProvPermission])
     def prov(self, request, pk=None):
         """
         View for /api/datasources/<int>/prov/
@@ -100,7 +102,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet):
         }
         return response.Response(data, status=200)
 
-    @decorators.action(detail=True)
+    @decorators.action(detail=True, permission_classes=[permissions.MetadataPermission])
     def metadata(self, request, pk=None):
         """
         View for /api/datasources/<int>/metadata/
@@ -117,7 +119,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet):
         return self.try_passthrough_response(map_response,
                                              'Data source does not provide metadata')
 
-    @decorators.action(detail=True)
+    @decorators.action(detail=True, permission_classes=[permissions.DataPermission])
     def data(self, request, pk=None):
         """
         View for /api/datasources/<int>/data/
@@ -132,7 +134,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet):
         return self.try_passthrough_response(map_response,
                                              'Data source does not provide data')
 
-    @decorators.action(detail=True)
+    @decorators.action(detail=True, permission_classes=[permissions.MetadataPermission])
     def datasets(self, request, pk=None):
         """
         View for /api/datasources/<int>/datasets/
@@ -151,7 +153,8 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet):
 
     # TODO URL pattern here uses pre Django 2 format
     @decorators.action(detail=True,
-                       url_path='datasets/(?P<href>.*)/metadata')
+                       url_path='datasets/(?P<href>.*)/metadata',
+                       permission_classes=[permissions.MetadataPermission])
     def dataset_metadata(self, request, pk=None, **kwargs):
         """
         View for /api/datasources/<int>/datasets/<href>/metadata/
@@ -171,7 +174,8 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet):
 
     # TODO URL pattern here uses pre Django 2 format
     @decorators.action(detail=True,
-                       url_path='datasets/(?P<href>.*)/data')
+                       url_path='datasets/(?P<href>.*)/data',
+                       permission_classes=[permissions.DataPermission])
     def dataset_data(self, request, pk=None, **kwargs):
         """
         View for /api/datasources/<int>/datasets/<href>/metadata/
diff --git a/applications/models.py b/applications/models.py
index c5496c34ae5da3969021d573da8c6eb293a34943..985ba161908e237bc4eda70c03a69a00a38ecdd6 100644
--- a/applications/models.py
+++ b/applications/models.py
@@ -1,4 +1,5 @@
 from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
 from django.conf import settings
 from django.db import models
 from django.urls import reverse
@@ -35,6 +36,67 @@ class Application(BaseAppDataModel):
                                       editable=False,
                                       blank=True, null=True)
 
+    #: Group of users who have read / use access to this data source / application
+    users_group = models.OneToOneField(Group,
+                                       on_delete=models.SET_NULL,
+                                       related_name='+',
+                                       editable=False,
+                                       blank=True, null=True)
+
+    #: Groups of users who have requested access to this data source / application
+    users_group_requested = models.OneToOneField(Group,
+                                                 on_delete=models.SET_NULL,
+                                                 related_name='+',
+                                                 editable=False,
+                                                 blank=True, null=True)
+
+    @property
+    def _access_group_name(self):
+        return str(type(self)) + ' ' + self.name + ' Users'
+
+    def save(self, **kwargs):
+        # Create access control groups if they do not exist
+        # Make sure their names match self.name if they do exist
+        if self.access_control:
+            if self.users_group:
+                # Update existing group name
+                self.users_group.name = self._access_group_name
+                self.users_group.save()
+
+            else:
+                self.users_group, created = Group.objects.get_or_create(
+                    name=self._access_group_name
+                )
+
+            if self.users_group_requested:
+                # Update existing group name
+                self.users_group_requested.name = self._access_group_name + ' Requested'
+                self.users_group_requested.save()
+
+            else:
+                self.users_group_requested, created = Group.objects.get_or_create(
+                    name=self._access_group_name + ' Requested'
+                )
+
+        if not self.proxy_user:
+            self.proxy_user = self._get_proxy_user()
+
+        super().save(**kwargs)
+
+    def has_view_permission(self, user: settings.AUTH_USER_MODEL) -> bool:
+        """
+        Does a user have permission to use this application?
+
+        :param user: User to check
+        :return: User has permission?
+        """
+        if not self.access_control:
+            return True
+        if self.owner == user:
+            return True
+
+        return self.users_group.user_set.filter(pk=user.pk).exists()
+
     def _get_proxy_user(self) -> settings.AUTH_USER_MODEL:
         """
         Create a new proxy user for this application or return the existing one.
@@ -52,12 +114,6 @@ class Application(BaseAppDataModel):
 
         return proxy_user
 
-    def save(self, **kwargs):
-        if not self.proxy_user:
-            self.proxy_user = self._get_proxy_user()
-
-        return super().save(**kwargs)
-
     def get_absolute_url(self):
         return reverse('applications:application.detail',
                        kwargs={'pk': self.pk})
diff --git a/core/models.py b/core/models.py
index f45646c4e5d52c71b72a6bf87a218c4412eee6d0..c968f1fce3bc4b776f4ea30db4347f7c3e3a045a 100644
--- a/core/models.py
+++ b/core/models.py
@@ -4,8 +4,6 @@ This module contains models for functionality common to both Application and Dat
 
 import abc
 
-from django.conf import settings
-from django.contrib.auth.models import Group
 from django.db import models
 
 
@@ -46,64 +44,6 @@ class BaseAppDataModel(models.Model):
         """
         raise NotImplementedError
 
-    #: Group of users who have read / use access to this data source / application
-    users_group = models.OneToOneField(Group,
-                                       on_delete=models.SET_NULL,
-                                       related_name='+',
-                                       editable=False,
-                                       blank=True, null=True)
-
-    #: Groups of users who have requested access to this data source / application
-    users_group_requested = models.OneToOneField(Group,
-                                                 on_delete=models.SET_NULL,
-                                                 related_name='+',
-                                                 editable=False,
-                                                 blank=True, null=True)
-
-    @property
-    def _access_group_name(self):
-        return str(type(self)) + ' ' + self.name + ' Users'
-
-    def save(self, **kwargs):
-        # Create access control groups if they do not exist
-        # Make sure their names match self.name if they do exist
-        if self.access_control:
-            if self.users_group:
-                # Update existing group name
-                self.users_group.name = self._access_group_name
-                self.users_group.save()
-
-            else:
-                self.users_group, created = Group.objects.get_or_create(
-                    name=self._access_group_name
-                )
-
-            if self.users_group_requested:
-                # Update existing group name
-                self.users_group_requested.name = self._access_group_name + ' Requested'
-                self.users_group_requested.save()
-
-            else:
-                self.users_group_requested, created = Group.objects.get_or_create(
-                    name=self._access_group_name + ' Requested'
-                )
-
-        super().save(**kwargs)
-
-    def has_view_permission(self, user: settings.AUTH_USER_MODEL) -> bool:
-        """
-        Does a user have permission to use this data source?
-
-        :param user: User to check
-        :return: User has permission?
-        """
-        if not self.access_control:
-            return True
-        if self.owner == user:
-            return True
-
-        return self.users_group.user_set.filter(pk=user.pk).exists()
-
     @abc.abstractmethod
     def get_absolute_url(self):
         """
diff --git a/datasources/forms.py b/datasources/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..6731712b6ea454dd24a8d12c3c33d655aa74958c
--- /dev/null
+++ b/datasources/forms.py
@@ -0,0 +1,15 @@
+from django import forms
+
+from . import models
+
+
+class PermissionRequestForm(forms.ModelForm):
+    class Meta:
+        model = models.UserPermissionLink
+        fields = ['requested', 'reason']
+
+
+class PermissionGrantForm(forms.ModelForm):
+    class Meta:
+        model = models.UserPermissionLink
+        fields = ['granted']
diff --git a/datasources/migrations/0011_datasource_permissions_table.py b/datasources/migrations/0011_datasource_permissions_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e74f808122a0bb8129c717824f9dae6338f33fc
--- /dev/null
+++ b/datasources/migrations/0011_datasource_permissions_table.py
@@ -0,0 +1,75 @@
+# Generated by Django 2.0.8 on 2018-11-06 15:29
+
+import datasources.models
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def datasource_permissions_forward(apps, schema_editor):
+    """
+    Copy user permissions from old group-based form to new linking-table form.
+    """
+    DataSource = apps.get_model('datasources', 'DataSource')
+    UserPermissionLink = apps.get_model('datasources', 'UserPermissionLink')
+
+    for data_source in DataSource.objects.all():
+        if data_source.access_control:
+            for user in data_source.users_group.user_set.all():
+                UserPermissionLink.objects.get_or_create(
+                    user=user,
+                    datasource=data_source,
+                    granted=datasources.models.UserPermissionLevels.VIEW
+                )
+
+            for user in data_source.users_group_requested.user_set.all():
+                link, created = UserPermissionLink.objects.get_or_create(
+                    user=user,
+                    datasource=data_source,
+                )
+                if not link.granted >= datasources.models.UserPermissionLevels.VIEW:
+                    link.requested = datasources.models.UserPermissionLevels.VIEW
+                    link.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('datasources', '0010_datasource_auth_method'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserPermissionLink',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('granted', models.IntegerField(choices=[('NONE', 0), ('VIEW', 1), ('META', 2), ('DATA', 3), ('PROV', 4)], default=datasources.models.UserPermissionLevels(0))),
+                ('requested', models.IntegerField(choices=[('NONE', 0), ('VIEW', 1), ('META', 2), ('DATA', 3), ('PROV', 4)], default=datasources.models.UserPermissionLevels(0))),
+            ],
+        ),
+        migrations.AddField(
+            model_name='userpermissionlink',
+            name='datasource',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='datasources.DataSource'),
+        ),
+        migrations.AddField(
+            model_name='userpermissionlink',
+            name='user',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AddField(
+            model_name='datasource',
+            name='users',
+            field=models.ManyToManyField(through='datasources.UserPermissionLink', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.RunPython(datasource_permissions_forward),
+        migrations.RemoveField(
+            model_name='datasource',
+            name='users_group',
+        ),
+        migrations.RemoveField(
+            model_name='datasource',
+            name='users_group_requested',
+        ),
+    ]
diff --git a/datasources/migrations/0012_add_request_reason.py b/datasources/migrations/0012_add_request_reason.py
new file mode 100644
index 0000000000000000000000000000000000000000..b73cf3988311032ec3d31aa6979f8d92e84b05c9
--- /dev/null
+++ b/datasources/migrations/0012_add_request_reason.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.0.8 on 2018-11-08 09:29
+
+import datasources.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('datasources', '0011_datasource_permissions_table'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='userpermissionlink',
+            name='reason',
+            field=models.CharField(blank=True, max_length=511),
+        ),
+        migrations.AlterField(
+            model_name='userpermissionlink',
+            name='granted',
+            field=models.IntegerField(choices=[(0, 'NONE'), (1, 'VIEW'), (2, 'META'), (3, 'DATA'), (4, 'PROV')], default=datasources.models.UserPermissionLevels(0)),
+        ),
+        migrations.AlterField(
+            model_name='userpermissionlink',
+            name='requested',
+            field=models.IntegerField(choices=[(0, 'NONE'), (1, 'VIEW'), (2, 'META'), (3, 'DATA'), (4, 'PROV')], default=datasources.models.UserPermissionLevels(0)),
+        ),
+    ]
diff --git a/datasources/models.py b/datasources/models.py
index a98fbf9952cf2eccf7c5a006f21a16322097969a..0386db69b9b65d3541cc809c66616f56154c5eaf 100644
--- a/datasources/models.py
+++ b/datasources/models.py
@@ -1,3 +1,4 @@
+import enum
 import json
 import typing
 
@@ -11,6 +12,61 @@ import requests.exceptions
 from core.models import BaseAppDataModel, MAX_LENGTH_API_KEY, MAX_LENGTH_NAME, MAX_LENGTH_PATH
 from datasources.connectors.base import AuthMethod, BaseDataConnector, REQUEST_AUTH_FUNCTIONS
 
+#: Length of request reason field - must include brief description of project
+MAX_LENGTH_REASON = 511
+
+
+@enum.unique
+class UserPermissionLevels(enum.IntEnum):
+    """
+    User permission levels on data sources.
+    """
+    #: No permissions
+    NONE = 0
+
+    #: Permission to view in PEDASI UI
+    VIEW = 1
+
+    #: Permission to query metadata via API / UI
+    META = 2
+
+    #: Permission to query data via API / UI
+    DATA = 3
+
+    #: Permission to query PROV via API / UI
+    PROV = 4
+
+    @classmethod
+    def choices(cls):
+        return tuple((i.value, i.name) for i in cls)
+
+
+class UserPermissionLink(models.Model):
+    """
+    Model to act as a many to many joining table to handle user permission levels for access to data sources.
+    """
+    #: User being managed
+    user = models.ForeignKey(settings.AUTH_USER_MODEL,
+                             on_delete=models.CASCADE)
+
+    #: Data source on which the permissions are being granted
+    datasource = models.ForeignKey('DataSource',
+                                   on_delete=models.CASCADE)
+
+    #: Granted permission level
+    granted = models.IntegerField(choices=UserPermissionLevels.choices(),
+                                  default=UserPermissionLevels.NONE,
+                                  blank=False, null=False)
+
+    #: Requested permission level
+    requested = models.IntegerField(choices=UserPermissionLevels.choices(),
+                                    default=UserPermissionLevels.NONE,
+                                    blank=False, null=False)
+
+    #: Reason the permission was requested
+    reason = models.CharField(max_length=MAX_LENGTH_REASON,
+                              blank=True, null=False)
+
 
 class DataSource(BaseAppDataModel):
     """
@@ -44,11 +100,15 @@ class DataSource(BaseAppDataModel):
     api_key = models.CharField(max_length=MAX_LENGTH_API_KEY,
                                blank=True, null=False)
 
-    #: Which authentication method to use - defined in datasources.connectors.base.AuthMethod enum
+    #: Which authentication method to use - defined in :class:`datasources.connectors.base.AuthMethod` enum
     auth_method = models.IntegerField(choices=AuthMethod.choices(),
                                       default=AuthMethod.UNKNOWN.value,
                                       editable=False, blank=False, null=False)
 
+    #: Users - linked via a permission table - see :class:`UserPermissionLink`
+    users = models.ManyToManyField(settings.AUTH_USER_MODEL,
+                                   through=UserPermissionLink)
+
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self._data_connector = None
@@ -60,6 +120,29 @@ class DataSource(BaseAppDataModel):
 
         return super().save(**kwargs)
 
+    def has_view_permission(self, user: settings.AUTH_USER_MODEL) -> bool:
+        """
+        Does a user have permission to view this data source in the PEDASI UI?
+
+        :param user: User to check
+        :return: User has permission?
+        """
+        if not self.access_control:
+            return True
+
+        if self.owner == user:
+            return True
+
+        try:
+            permission = UserPermissionLink.objects.get(
+                user=user,
+                datasource=self
+            )
+        except UserPermissionLink.DoesNotExist:
+            return False
+
+        return permission.granted >= UserPermissionLevels.VIEW
+
     @property
     def is_catalogue(self) -> bool:
         return self.data_connector_class.is_catalogue
diff --git a/datasources/templates/datasources/datasource/detail-no-access.html b/datasources/templates/datasources/datasource/detail-no-access.html
index fcdddd1a39e15d541976c7e30d07169c603c5e5c..a1bce56974966e161d087c160773f527b42e55ed 100644
--- a/datasources/templates/datasources/datasource/detail-no-access.html
+++ b/datasources/templates/datasources/datasource/detail-no-access.html
@@ -20,43 +20,53 @@
         </ol>
     </nav>
 
-    <h2>View Data Source - {{ datasource.name }}</h2>
+
+    <div class="row">
+        <div class="col-md-10 col-sm-8">
+            <h2>{{ datasource.name }}</h2>
+
+            {% if datasource.description %}
+                {{ datasource.description|linebreaks }}
+            {% endif %}
+        </div>
+
+        <div class="col-md-2 col-sm-4">
+            {% if datasource.access_control and request.user != datasource.owner and not request.user.is_superuser %}
+                <a href="{% url 'datasources:datasource.access.request' pk=datasource.id %}"
+                   class="btn btn-block btn-secondary" role="button">Manage My Access</a>
+            {% endif %}
+        </div>
+    </div>
 
     <div class="alert alert-warning">
         You do not have permission to access this resource.
 
-        {% if datasource.users_group_requested in request.user.groups.all %}
-            <button id="btn-request-access" disabled
-                    class="btn btn-primary" role="button">Access Requested</button>
+        {% if request.user in datasource.users.all %}
+            Please wait for your request to be approved.
 
         {% elif not request.user.is_authenticated %}
             You must be logged in to request access.
-
-        {% else %}
-            <button id="btn-request-access" onclick="requestAccess()"
-                    class="btn btn-primary" role="button">Request Access</button>
-
-            <script type="application/javascript">
-                function requestAccess(){
-                    $.ajax({
-                        url: '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=request.user.pk %}',
-                        headers: {
-                            'X-CSRFToken': Cookies.get('csrftoken')
-                        },
-                        method: 'PUT',
-                        success: function(result, status, xhr){
-                            const btn = document.getElementById('btn-request-access');
-                            btn.textContent = 'Access Requested';
-                            btn.disabled = true;
-                        }
-                    })
-                }
-            </script>
         {% endif %}
     </div>
 
-    {% if datasource.description %}
-        <p>{{ datasource.description }}</p>
-    {% endif %}
+    <table class="table">
+        <thead>
+        <th scope="col" class="col-md-2 border-0"></th>
+        <th scope="col" class="border-0"></th>
+        </thead>
+
+        <tbody>
+        <tr>
+            <td>Owner</td>
+            <td>
+                {{ datasource.owner }}
+            </td>
+        </tr>
+        <tr>
+            <td>URL</td>
+            <td>{{ datasource.url }}</td>
+        </tr>
+        </tbody>
+    </table>
 
 {% endblock %}
\ No newline at end of file
diff --git a/datasources/templates/datasources/datasource/detail.html b/datasources/templates/datasources/datasource/detail.html
index eaeecbec0231bf0e9b2e290ab6cf03bf859c91ab..8fdba0376e061411d06dd5f32d0dcec9b61f1bdb 100644
--- a/datasources/templates/datasources/datasource/detail.html
+++ b/datasources/templates/datasources/datasource/detail.html
@@ -20,18 +20,23 @@
     <div class="row">
         <div class="col-md-10 col-sm-8">
             <h2>{{ datasource.name }}</h2>
+
+            {% if datasource.description %}
+                {{ datasource.description|linebreaks }}
+            {% endif %}
         </div>
 
         <div class="col-md-2 col-sm-4">
             <a href="{% url 'api:datasource-detail' pk=datasource.id %}"
                class="btn btn-block btn-info" role="button">API Explorer</a>
+
+            {% if datasource.access_control and request.user != datasource.owner and not request.user.is_superuser %}
+                <a href="{% url 'datasources:datasource.access.request' pk=datasource.id %}"
+                   class="btn btn-block btn-secondary" role="button">Manage My Access</a>
+            {% endif %}
         </div>
     </div>
 
-    {% if datasource.description %}
-        {{ datasource.description|linebreaks }}
-    {% endif %}
-
     <table class="table">
         <thead>
             <th scope="col" class="col-md-2 border-0"></th>
@@ -53,14 +58,14 @@
     </table>
 
     <div class="row justify-content-sm-center">
-        {% if datasource.access_control %}
-            <div class="col-md-2 col-sm-4">
-                <a href="{% url 'datasources:datasource.manage-access' pk=datasource.pk %}"
-                   class="btn btn-block col btn-primary" role="button">Manage Access</a>
-            </div>
-        {% endif %}
-
         {% if has_edit_permission %}
+            {% if datasource.access_control %}
+                <div class="col-md-2 col-sm-4">
+                    <a href="{% url 'datasources:datasource.access.manage' pk=datasource.pk %}"
+                       class="btn btn-block col btn-primary" role="button">Manage Access</a>
+                </div>
+            {% endif %}
+
             <div class="col-md-2 col-sm-4">
                 <a href="{% url 'admin:datasources_datasource_change' datasource.id %}"
                    class="btn btn-block btn-success" role="button">Edit</a>
diff --git a/datasources/templates/datasources/datasource/list.html b/datasources/templates/datasources/datasource/list.html
index dafe837d2e9fe10a357ace2f065f24839710afda..6ac0f461fd82a2f5c3dde396e3f6c8e289571345 100644
--- a/datasources/templates/datasources/datasource/list.html
+++ b/datasources/templates/datasources/datasource/list.html
@@ -59,7 +59,7 @@
                 </td>
                 <td>
                     <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}"
-                       class="btn btn-block btn-primary" role="button">Detail</a>
+                       class="btn btn-block btn-secondary" role="button">Detail</a>
                 </td>
             </tr>
             {% empty %}
diff --git a/datasources/templates/datasources/datasource/manage_access.html b/datasources/templates/datasources/datasource/manage_access.html
index e788956b7b0ed4e59d7fe190cc98bf79e6b1ceec..bd1459dfe2f10361ea9dc263e957c68702369d83 100644
--- a/datasources/templates/datasources/datasource/manage_access.html
+++ b/datasources/templates/datasources/datasource/manage_access.html
@@ -37,28 +37,39 @@
         <thead class="thead">
             <tr>
                 <th>Username</th>
+                <th>Requested</th>
+                <th>Current</th>
+                <th></th>
                 <th></th>
                 <th></th>
             </tr>
         </thead>
 
         <tbody>
-            {% for user in datasource.users_group_requested.user_set.all %}
-                <tr id="requested-user-{{ user.pk }}">
-                    <td>{{ user.username }}</td>
+            {% for permission in permissions_requested.all %}
+                <tr id="requested-user-{{ permission.user.pk }}">
+                    <td>
+                        <p>
+                            {{ permission.user.username }}
+                        </p>
+                        <div class="alert alert-secondary" role="note">
+                            {{ permission.reason }}
+                        </div>
+                    </td>
+                    <td>{{ permission.get_requested_display }}</td>
+                    <td>{{ permission.get_granted_display }}</td>
                     <td>
-                        <button onclick="userGrantAccess(
-                                            '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=user.pk %}',
-                                            {{ user.pk }}
-                                )"
+                        <button onclick="userGrantAccess({{ permission.user.pk }}, {{ permission.requested }})"
                                 class="btn btn-success"
                                 role="button">Approve</button>
                     </td>
                     <td>
-                        <button onclick="userRemoveAccess(
-                                    '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=user.pk %}',
-                                    {{ user.pk }}
-                                )"
+                        <a href="{% url 'datasources:datasource.access.grant' pk=datasource.pk %}?user={{ permission.user_id }}"
+                           class="btn btn-info"
+                           role="button">Edit</a>
+                    </td>
+                    <td>
+                        <button onclick="userGrantAccess({{ permission.user.pk }}, {{ permission.granted }})"
                                 class="btn btn-danger"
                                 role="button">Reject</button>
                     </td>
@@ -77,21 +88,27 @@
         <thead class="thead">
         <tr>
             <th>Username</th>
+            <th>Requested</th>
+            <th>Current</th>
+            <th></th>
             <th></th>
             <th></th>
         </tr>
         </thead>
 
         <tbody>
-        {% for user in datasource.users_group.user_set.all %}
-            <tr id="approved-user-{{ user.pk }}">
-                <td>{{ user.username }}</td>
-                <td></td>
+        {% for permission in permissions_granted %}
+            <tr id="approved-user-{{ permission.user.pk }}">
+                <td>{{ permission.user.username }}</td>
+                <td>{{ permission.get_requested_display }}</td>
+                <td>{{ permission.get_granted_display }}</td>
                 <td>
-                    <button onclick="userRemoveAccess(
-                                '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=user.pk %}',
-                                {{ user.pk }}
-                            )"
+                    <a href="{% url 'datasources:datasource.access.grant' pk=datasource.pk %}?user={{ permission.user_id }}"
+                       class="btn btn-info"
+                       role="button">Edit</a>
+                </td>
+                <td>
+                    <button onclick="userRemoveAccess({{ permission.user.pk }})"
                             class="btn btn-danger"
                             role="button">Remove</button>
                 </td>
@@ -103,14 +120,17 @@
     </table>
 
     <script type="application/javascript">
-        function userGrantAccess(url, userPk){
-            $.ajax({
-                {#url: '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=request.user.pk %}',#}
-                url: url,
+        function userGrantAccess(userPk, level){
+            $.post({
+                url: '{% url 'datasources:datasource.access.grant' pk=datasource.pk %}',
+                data: {
+                    'user': userPk,
+                    'granted': level,
+                    'requested': level
+                },
                 headers: {
                     'X-CSRFToken': Cookies.get('csrftoken')
                 },
-                method: 'PUT',
                 success: function(result, status, xhr){
                     document.getElementById('requested-user-' + userPk.toString()).remove();
 
@@ -119,14 +139,17 @@
             })
         }
 
-        function userRemoveAccess(url, userPk){
-            $.ajax({
-                {#url: '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=request.user.pk %}',#}
-                url: url,
+        function userRemoveAccess(userPk){
+            $.post({
+                url: '{% url 'datasources:datasource.access.grant' pk=datasource.pk %}',
+                data: {
+                    'user': userPk,
+                    'granted': 0,
+                    'requested': 0
+                },
                 headers: {
                     'X-CSRFToken': Cookies.get('csrftoken')
                 },
-                method: 'DELETE',
                 success: function(result, status, xhr){
                     try {
                         document.getElementById('approved-user-' + userPk.toString()).remove();
@@ -135,9 +158,9 @@
                     try {
                         document.getElementById('requested-user-' + userPk.toString()).remove();
                     } catch (err) {}
-                }
 
-                // TODO if table is empty add 'table is empty' row
+                    // TODO if table is empty add 'table is empty' row
+                }
             })
         }
     </script>
diff --git a/datasources/templates/datasources/user_permission_link/permission_grant.html b/datasources/templates/datasources/user_permission_link/permission_grant.html
new file mode 100644
index 0000000000000000000000000000000000000000..916c912068bc58a922771bfe9fa23cafc5f75848
--- /dev/null
+++ b/datasources/templates/datasources/user_permission_link/permission_grant.html
@@ -0,0 +1,69 @@
+{% extends "base.html" %}
+{% load bootstrap4 %}
+
+{% block content %}
+    <nav aria-label="breadcrumb">
+        <ol class="breadcrumb">
+            <li class="breadcrumb-item" aria-current="page">
+                <a href="{% url 'index' %}">Home</a>
+            </li>
+            <li class="breadcrumb-item" aria-current="page">
+                <a href="{% url 'datasources:datasource.list' %}">Data Sources</a>
+            </li>
+            <li class="breadcrumb-item" aria-current="page">
+                <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}">{{ datasource.name }}</a>
+            </li>
+            <li class="breadcrumb-item active" aria-current="page">
+                Manage Access
+            </li>
+        </ol>
+    </nav>
+
+
+    <h2>{{ datasource.name }}</h2>
+
+    {% if datasource.description %}
+        {{ datasource.description|linebreaks }}
+    {% endif %}
+
+    <table class="table">
+        <thead>
+            <th scope="col" class="col-md-2 border-0"></th>
+            <th scope="col" class="border-0"></th>
+        </thead>
+
+        <tbody>
+            <tr>
+                <td>Owner</td>
+                <td>
+                    {{ datasource.owner }}
+                </td>
+            </tr>
+            <tr>
+                <td>URL</td>
+                <td>{{ datasource.url }}</td>
+            </tr>
+        </tbody>
+    </table>
+
+    <h2>Access Request</h2>
+
+    {{ permission.user.username }} requests {{ permission.get_requested_display }} permission on this data source
+
+    <div class="alert alert-secondary" role="note">
+        <p>
+            {{ permission.reason|linebreaks }}
+        </p>
+    </div>
+
+    <form method="post">
+        {% csrf_token %}
+        {% bootstrap_form form %}
+
+        {% buttons %}
+            <input type="submit" class="btn btn-success" value="Request">
+             <button onclick="window.history.back();" class="btn btn-default" type="button">Cancel</button>
+        {% endbuttons %}
+    </form>
+
+{% endblock %}
\ No newline at end of file
diff --git a/datasources/templates/datasources/user_permission_link/permission_request.html b/datasources/templates/datasources/user_permission_link/permission_request.html
new file mode 100644
index 0000000000000000000000000000000000000000..6ce6c7f33112a8f06f982dff7dcf8653ebe4c61f
--- /dev/null
+++ b/datasources/templates/datasources/user_permission_link/permission_request.html
@@ -0,0 +1,61 @@
+{% extends "base.html" %}
+{% load bootstrap4 %}
+
+{% block content %}
+    <nav aria-label="breadcrumb">
+        <ol class="breadcrumb">
+            <li class="breadcrumb-item" aria-current="page">
+                <a href="{% url 'index' %}">Home</a>
+            </li>
+            <li class="breadcrumb-item" aria-current="page">
+                <a href="{% url 'datasources:datasource.list' %}">Data Sources</a>
+            </li>
+            <li class="breadcrumb-item" aria-current="page">
+                <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}">{{ datasource.name }}</a>
+            </li>
+            <li class="breadcrumb-item active" aria-current="page">
+                Manage Access
+            </li>
+        </ol>
+    </nav>
+
+
+    <h2>{{ datasource.name }}</h2>
+
+    {% if datasource.description %}
+        {{ datasource.description|linebreaks }}
+    {% endif %}
+
+    <table class="table">
+        <thead>
+            <th scope="col" class="col-md-2 border-0"></th>
+            <th scope="col" class="border-0"></th>
+        </thead>
+
+        <tbody>
+            <tr>
+                <td>Owner</td>
+                <td>
+                    {{ datasource.owner }}
+                </td>
+            </tr>
+            <tr>
+                <td>URL</td>
+                <td>{{ datasource.url }}</td>
+            </tr>
+        </tbody>
+    </table>
+
+    <h2>Access Request</h2>
+
+    <form method="post">
+        {% csrf_token %}
+        {% bootstrap_form form %}
+
+        {% buttons %}
+            <input type="submit" class="btn btn-success" value="Request">
+             <button onclick="window.history.back();" class="btn btn-default" type="button">Cancel</button>
+        {% endbuttons %}
+    </form>
+
+{% endblock %}
\ No newline at end of file
diff --git a/datasources/urls.py b/datasources/urls.py
index 1747efc4f223438792095ec0efe3fd9d402700bf..cdba90c399196596ad1e8384d54529d2484585a2 100644
--- a/datasources/urls.py
+++ b/datasources/urls.py
@@ -6,34 +6,41 @@ app_name = 'datasources'
 
 urlpatterns = [
     path('',
-         views.DataSourceListView.as_view(),
+         views.datasource.DataSourceListView.as_view(),
          name='datasource.list'),
 
     path('<int:pk>/',
-         views.DataSourceDetailView.as_view(),
+         views.datasource.DataSourceDetailView.as_view(),
          name='datasource.detail'),
 
-    path('<int:pk>/manage-access',
-         views.DataSourceManageAccessView.as_view(),
-         name='datasource.manage-access'),
-
-    path('<int:pk>/users/<int:user_pk>',
-         views.DataSourceRequestAccessView.as_view(),
-         name='datasource.manage-access.user'),
-
     path('<int:pk>/query',
-         views.DataSourceQueryView.as_view(),
+         views.datasource.DataSourceQueryView.as_view(),
          name='datasource.query'),
 
     path('<int:pk>/metadata',
-         views.DataSourceMetadataView.as_view(),
+         views.datasource.DataSourceMetadataView.as_view(),
          name='datasource.metadata'),
 
     path('<int:pk>/explore',
-         views.DataSourceExploreView.as_view(),
+         views.datasource.DataSourceExploreView.as_view(),
          name='datasource.explore'),
 
     path('<int:pk>/search',
-         views.DataSourceDataSetSearchView.as_view(),
+         views.datasource.DataSourceDataSetSearchView.as_view(),
          name='datasource.dataset.search'),
+
+    #######################
+    # Permission management
+
+    path('<int:pk>/access',
+         views.user_permission_link.DataSourceAccessManageView.as_view(),
+         name='datasource.access.manage'),
+
+    path('<int:pk>/access/request',
+         views.user_permission_link.DataSourceAccessRequestView.as_view(),
+         name='datasource.access.request'),
+
+    path('<int:pk>/access/grant',
+         views.user_permission_link.DataSourceAccessGrantView.as_view(),
+         name='datasource.access.grant'),
 ]
diff --git a/datasources/views.py b/datasources/views.py
deleted file mode 100644
index 589facbde5c9b0c72d5a5bd6d28e4e0af467c7ab..0000000000000000000000000000000000000000
--- a/datasources/views.py
+++ /dev/null
@@ -1,224 +0,0 @@
-from django.contrib.auth import get_user_model
-from django.http import JsonResponse, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
-from django.views.generic import View
-from django.views.generic.detail import DetailView, SingleObjectMixin
-from django.views.generic.list import ListView
-
-import requests.exceptions
-
-from profiles.permissions import HasViewPermissionMixin, OwnerPermissionRequiredMixin
-from datasources import models
-
-
-class DataSourceListView(ListView):
-    model = models.DataSource
-    template_name = 'datasources/datasource/list.html'
-    context_object_name = 'datasources'
-
-
-class DataSourceDetailView(DetailView):
-    model = models.DataSource
-    template_name = 'datasources/datasource/detail.html'
-    context_object_name = 'datasource'
-
-    def get_template_names(self):
-        if not self.object.has_view_permission(self.request.user):
-            return ['datasources/datasource/detail-no-access.html']
-        return super().get_template_names()
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-
-        context['has_edit_permission'] = self.request.user.is_staff or self.request.user == self.object.owner
-
-        return context
-
-
-class DataSourceDataSetSearchView(DetailView):
-    model = models.DataSource
-    template_name = 'datasources/datasource/dataset_search.html'
-    context_object_name = 'datasource'
-
-    def get(self, request, *args, **kwargs):
-        try:
-            return super().get(request, *args, **kwargs)
-        except requests.exceptions.HTTPError as e:
-            return HttpResponse(
-                'API call failed',
-                # Pass status code through unless it was 200 OK
-                status=424 if e.response.status_code == 200 else e.response.status_code
-            )
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-
-        connector = self.object.data_connector
-        try:
-            datasets = list(connector.items(
-                params={
-                    'prefix-val': self.request.GET.get('q')
-                }
-            ))
-            context['datasets'] = datasets
-
-            # Check the metadata format of the first dataset
-            # TODO will all metadata formats be the same
-            if isinstance(datasets[0][1].get_metadata(), list):
-                context['metadata_type'] = 'list'
-            else:
-                context['metadata_type'] = 'dict'
-
-        except AttributeError:
-            # DataSource is not a catalogue
-            pass
-
-        return context
-
-
-class DataSourceManageAccessView(OwnerPermissionRequiredMixin, DetailView):
-    model = models.DataSource
-    template_name = 'datasources/datasource/manage_access.html'
-    context_object_name = 'datasource'
-
-    permission_required = 'datasources.change_datasource'
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-
-        context['all_users'] = get_user_model().objects
-
-        return context
-
-
-class DataSourceRequestAccessView(SingleObjectMixin, View):
-    """
-    Manage a user's access to a DataSource.
-
-    Accepts PUT and DELETE requests to add a user to, or remove a user from the access group.
-    Request responses follow JSend specification (see http://labs.omniti.com/labs/jsend).
-    """
-    model = models.DataSource
-
-    def put(self, request, *args, **kwargs):
-        """
-        Add a user to the access group for a DataSource.
-
-        If the request is performed by the DataSource owner or by staff: add them directly to the access group,
-        If the request is performed by the user themselves: add them to the 'access requested' group,
-        Else reject the request.
-
-        :param request:
-        :param args:
-        :param kwargs:
-        :return:
-        """
-        self.request = request
-        self.object = self.get_object()
-
-        user = get_user_model().objects.get(pk=kwargs['user_pk'])
-        access_group = self.object.users_group
-        request_group = self.object.users_group_requested
-
-        if self.request.user == self.object.owner or self.request.user.is_staff:
-            # If request is from DataSource owner or staff, add user to access group
-            access_group.user_set.add(user)
-            request_group.user_set.remove(user)
-
-        elif self.request.user == user:
-            if access_group.user_set.filter(id=user.id).exists():
-                return HttpResponseBadRequest(
-                    JsonResponse({
-                        'status': 'fail',
-                        'message': 'You already have access to this resource',
-                    })
-                )
-
-            # If user is requesting for themselves, add them to 'access requested' group
-            request_group.user_set.add(user)
-
-        else:
-            return HttpResponseForbidden(
-                JsonResponse({
-                    'status': 'fail',
-                    'message': 'You do not have permission to set access for this user',
-                })
-            )
-
-        return JsonResponse({
-            'status': 'success',
-            'data': {
-                'user': {
-                    'pk': user.pk
-                },
-            },
-        })
-
-    def delete(self, request, *args, **kwargs):
-        self.request = request
-        self.object = self.get_object()
-
-        user = get_user_model().objects.get(pk=kwargs['user_pk'])
-        access_group = self.object.users_group
-        request_group = self.object.users_group_requested
-
-        if self.request.user == user or self.request.user == self.object.owner or self.request.user.is_staff:
-            # Users can remove themselves, be removed by the DataSource owner, or by staff
-            access_group.user_set.remove(user)
-            request_group.user_set.remove(user)
-
-        else:
-            return HttpResponseForbidden(
-                JsonResponse({
-                    'status': 'fail',
-                    'message': 'You do not have permission to set access for this user',
-                })
-            )
-
-        return JsonResponse({
-            'status': 'success',
-            'data': {
-                'user': {
-                    'pk': user.pk
-                },
-            },
-        })
-
-
-class DataSourceQueryView(HasViewPermissionMixin, DetailView):
-    model = models.DataSource
-    template_name = 'datasources/datasource/query.html'
-    context_object_name = 'datasource'
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-
-        context['results'] = self.object.data_connector.get_data(
-            params={'year': 2018}
-        )
-
-        return context
-
-
-class DataSourceMetadataView(HasViewPermissionMixin, DetailView):
-    model = models.DataSource
-    template_name = 'datasources/datasource/metadata.html'
-    context_object_name = 'datasource'
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-
-        # Using data connector context manager saves API queries
-        with self.object.data_connector as dc:
-            context['metadata'] = dc.get_metadata()
-            context['datasets'] = {
-                dataset: dc.get_metadata(dataset)
-                for dataset in dc.get_datasets()
-            }
-
-        return context
-
-
-class DataSourceExploreView(HasViewPermissionMixin, DetailView):
-    model = models.DataSource
-    template_name = 'datasources/datasource/explore.html'
-    context_object_name = 'datasource'
diff --git a/datasources/views/__init__.py b/datasources/views/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..825d77f60d80dc456a8e2cfeec611f73f246e674
--- /dev/null
+++ b/datasources/views/__init__.py
@@ -0,0 +1 @@
+from . import datasource, user_permission_link
\ No newline at end of file
diff --git a/datasources/views/datasource.py b/datasources/views/datasource.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc2f6ccf802075eb05b6f947a4f2c95a6c10586c
--- /dev/null
+++ b/datasources/views/datasource.py
@@ -0,0 +1,113 @@
+from django.http import HttpResponse
+from django.views.generic.detail import DetailView
+from django.views.generic.list import ListView
+
+import requests.exceptions
+
+from datasources import models
+from profiles.permissions import HasViewPermissionMixin
+
+
+class DataSourceListView(ListView):
+    model = models.DataSource
+    template_name = 'datasources/datasource/list.html'
+    context_object_name = 'datasources'
+
+
+class DataSourceDetailView(DetailView):
+    model = models.DataSource
+    template_name = 'datasources/datasource/detail.html'
+    context_object_name = 'datasource'
+
+    def get_template_names(self):
+        if not self.object.has_view_permission(self.request.user):
+            return ['datasources/datasource/detail-no-access.html']
+        return super().get_template_names()
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        context['has_edit_permission'] = self.request.user.is_staff or self.request.user == self.object.owner
+
+        return context
+
+
+class DataSourceDataSetSearchView(DetailView):
+    model = models.DataSource
+    template_name = 'datasources/datasource/dataset_search.html'
+    context_object_name = 'datasource'
+
+    def get(self, request, *args, **kwargs):
+        try:
+            return super().get(request, *args, **kwargs)
+        except requests.exceptions.HTTPError as e:
+            return HttpResponse(
+                'API call failed',
+                # Pass status code through unless it was 200 OK
+                status=424 if e.response.status_code == 200 else e.response.status_code
+            )
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        connector = self.object.data_connector
+        try:
+            datasets = list(connector.items(
+                params={
+                    'prefix-val': self.request.GET.get('q')
+                }
+            ))
+            context['datasets'] = datasets
+
+            # Check the metadata format of the first dataset
+            # TODO will all metadata formats be the same
+            if isinstance(datasets[0][1].get_metadata(), list):
+                context['metadata_type'] = 'list'
+            else:
+                context['metadata_type'] = 'dict'
+
+        except AttributeError:
+            # DataSource is not a catalogue
+            pass
+
+        return context
+
+
+class DataSourceQueryView(HasViewPermissionMixin, DetailView):
+    model = models.DataSource
+    template_name = 'datasources/datasource/query.html'
+    context_object_name = 'datasource'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        context['results'] = self.object.data_connector.get_data(
+            params={'year': 2018}
+        )
+
+        return context
+
+
+class DataSourceMetadataView(HasViewPermissionMixin, DetailView):
+    model = models.DataSource
+    template_name = 'datasources/datasource/metadata.html'
+    context_object_name = 'datasource'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        # Using data connector context manager saves API queries
+        with self.object.data_connector as dc:
+            context['metadata'] = dc.get_metadata()
+            context['datasets'] = {
+                dataset: dc.get_metadata(dataset)
+                for dataset in dc.get_datasets()
+            }
+
+        return context
+
+
+class DataSourceExploreView(HasViewPermissionMixin, DetailView):
+    model = models.DataSource
+    template_name = 'datasources/datasource/explore.html'
+    context_object_name = 'datasource'
diff --git a/datasources/views/user_permission_link.py b/datasources/views/user_permission_link.py
new file mode 100644
index 0000000000000000000000000000000000000000..41ebc64ed0be616d46069af561bf14d487710787
--- /dev/null
+++ b/datasources/views/user_permission_link.py
@@ -0,0 +1,175 @@
+from django.contrib.auth import get_user_model
+from django.db.models import F, ObjectDoesNotExist
+from django.http import HttpResponseRedirect
+from django.shortcuts import reverse
+from django.views.generic.detail import DetailView
+from django.views.generic.edit import UpdateView
+
+from profiles.permissions import OwnerPermissionRequiredMixin
+from datasources import forms, models
+
+
+class DataSourceAccessManageView(OwnerPermissionRequiredMixin, DetailView):
+    model = models.DataSource
+    template_name = 'datasources/datasource/manage_access.html'
+    context_object_name = 'datasource'
+
+    permission_required = 'datasources.change_datasource'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        context['all_users'] = get_user_model().objects
+
+        context['permissions_requested'] = models.UserPermissionLink.objects.filter(
+            datasource=self.object,
+            requested__gt=F('granted')
+        )
+
+        context['permissions_granted'] = models.UserPermissionLink.objects.filter(
+            datasource=self.object,
+            requested__lte=F('granted'),
+            # granted__gt=models.UserPermissionLevels.NONE
+        )
+
+        return context
+
+
+class DataSourceAccessGrantView(UpdateView):
+    """
+    Manage a user's access to a DataSource.
+
+    Provides a form view to edit permissions, but permissions may also be set using an AJAX POST request.
+    """
+    model = models.UserPermissionLink
+    form_class = forms.PermissionGrantForm
+    context_object_name = 'permission'
+    template_name = 'datasources/user_permission_link/permission_grant.html'
+
+    def get_context_data(self, **kwargs):
+        """
+        Add data source to the context.
+        """
+        context = super().get_context_data()
+
+        context['datasource'] = models.DataSource.objects.get(pk=self.kwargs['pk'])
+
+        return context
+
+    def get_object(self, queryset=None):
+        """
+        Get or create a permission object for the relevant user.
+        """
+        self.datasource = models.DataSource.objects.get(pk=self.kwargs['pk'])
+
+        try:
+            user = get_user_model().objects.get(id=self.request.POST.get('user'))
+
+        except get_user_model().DoesNotExist:
+            user = get_user_model().objects.get(id=self.request.GET.get('user'))
+
+        obj, created = self.model.objects.get_or_create(
+            user=user,
+            datasource=self.datasource
+        )
+
+        # Set default value to approve request - but do not automatically save this
+        obj.granted = obj.requested
+
+        return obj
+
+    def form_valid(self, form):
+        """
+        Automatically grant requests which are either:
+            - Edited by owner / admin
+            - Requests for a reduction in permission level
+        """
+        form.instance.requested = form.instance.granted
+
+        if form.instance.requested == models.UserPermissionLevels.NONE:
+            form.instance.delete()
+
+        else:
+            form.instance.save()
+
+        return HttpResponseRedirect(self.get_success_url())
+
+    def get_success_url(self):
+        """
+        Return to access management view.
+        """
+        return reverse('datasources:datasource.access.manage', kwargs={'pk': self.datasource.pk})
+
+
+class DataSourceAccessRequestView(UpdateView):
+    """
+    Request access to a data source, or request changes to an existing permission.
+
+    Provides a form view to edit permission requests, but permissions may also be requested using an AJAX POST request.
+    """
+    model = models.UserPermissionLink
+    form_class = forms.PermissionRequestForm
+    template_name = 'datasources/user_permission_link/permission_request.html'
+
+    def get_context_data(self, **kwargs):
+        """
+        Add data source to the context.
+        """
+        context = super().get_context_data()
+
+        context['datasource'] = models.DataSource.objects.get(pk=self.kwargs['pk'])
+
+        return context
+
+    def get_object(self, queryset=None):
+        """
+        Get or create a permission object for the relevant user.
+        """
+        self.datasource = models.DataSource.objects.get(pk=self.kwargs['pk'])
+        user = self.request.user
+
+        if self.request.user == self.datasource.owner or self.request.user.is_superuser:
+            try:
+                # Let owner and admins edit other user's requests
+                user = get_user_model().objects.get(id=self.request.GET.get('user'))
+
+            except ObjectDoesNotExist:
+                pass
+
+        obj, created = self.model.objects.get_or_create(
+            user=user,
+            datasource=self.datasource
+        )
+
+        return obj
+
+    def form_valid(self, form):
+        """
+        Automatically grant requests which are either:
+            - Edited by owner / admin
+            - Requests for a reduction in permission level
+        """
+        if form.instance.requested == models.UserPermissionLevels.NONE:
+            form.instance.delete()
+
+        else:
+            if (
+                    self.request.user == self.datasource.owner or self.request.user.is_superuser or
+                    form.instance.granted > form.instance.requested
+            ):
+                form.instance.granted = form.instance.requested
+
+            form.instance.save()
+
+        return HttpResponseRedirect(self.get_success_url())
+
+    def get_success_url(self):
+        """
+        Return to the data source or access management view depending on user class.
+        """
+        if self.request.user == self.datasource.owner or self.request.user.is_superuser:
+            return reverse('datasources:datasource.access.manage', kwargs={'pk': self.datasource.pk})
+
+        return reverse('datasources:datasource.detail', kwargs={'pk': self.datasource.pk})
+
+