diff --git a/applications/models.py b/applications/models.py
index 189ce917460ac3b3462a9b37e7edc5f8eca9aec2..33fc2eca58c5c78c0dce1483ae5f9d81a5526f34 100644
--- a/applications/models.py
+++ b/applications/models.py
@@ -8,9 +8,10 @@ from django.utils.text import slugify
 from rest_framework.authtoken.models import Token
 
 from core.models import BaseAppDataModel, SoftDeletionManager
+from provenance.models import ProvAbleModel, ProvApplicationModel
 
 
-class Application(BaseAppDataModel):
+class Application(ProvAbleModel, ProvApplicationModel, BaseAppDataModel):
     """
     Manage the state of and access to an external application.
 
diff --git a/datasources/models.py b/datasources/models.py
index 59427541b2da0f3b1a103ae7e54a2292a0b53a40..e7c1b70fcfbba5e81ebb70ab6a16b56fd0097c4d 100644
--- a/datasources/models.py
+++ b/datasources/models.py
@@ -11,11 +11,10 @@ from django.conf import settings
 from django.core import validators
 from django.db import models
 from django.urls import reverse
-import requests
-import requests.exceptions
 
 from core.models import BaseAppDataModel, MAX_LENGTH_API_KEY, MAX_LENGTH_NAME, MAX_LENGTH_PATH, SoftDeletionManager
 from datasources.connectors.base import AuthMethod, BaseDataConnector, REQUEST_AUTH_FUNCTIONS
+from provenance.models import ProvAbleModel
 
 #: Length of request reason field - must include brief description of project
 MAX_LENGTH_REASON = 511
@@ -207,7 +206,7 @@ class UserPermissionLink(models.Model):
         unique_together = (('user', 'datasource'),)
 
 
-class DataSource(BaseAppDataModel):
+class DataSource(ProvAbleModel, BaseAppDataModel):
     """
     Manage access to a data source API.
 
@@ -305,7 +304,7 @@ class DataSource(BaseAppDataModel):
         # TODO avoid determining auth method if existing one still works
         self.auth_method = self.data_connector_class.determine_auth_method(self.url, self.api_key)
 
-        return super().save(*args, **kwargs)
+        super().save(*args, **kwargs)
 
     def delete(self, using=None, keep_parents=False):
         """
diff --git a/provenance/models.py b/provenance/models.py
index f1aa4a711ecd30a171297abba1af89de88ef8430..e94d04867341e9af939cb6b1ac773bccb24be8ec 100644
--- a/provenance/models.py
+++ b/provenance/models.py
@@ -13,8 +13,7 @@ import uuid
 
 from django import apps
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import QuerySet, signals
-from django.dispatch import receiver
+from django.db.models import QuerySet
 from django.utils import timezone
 from django.utils.text import slugify
 
@@ -22,27 +21,29 @@ import mongoengine
 from mongoengine.queryset.visitor import Q
 import prov.model
 
-from applications.models import Application
 from core.models import BaseAppDataModel
-from datasources.models import DataSource
 
 MAX_LENGTH_NAME_FIELD = 100
 
 
-class PedasiDummyApplication:
+class ProvApplicationModel:
     """
     Dummy application model to fall back to when an action was performed via the PEDASI web interface.
+
+    Also to be used as parent class of :class:`Application` to help with type hinting.
     """
-    name = 'PEDASI'
-    pk = 'pedasi'  # We convert pk to string anyway - so this is fine
+    def __init__(self, *args, **kwargs):
+        self.pk = kwargs.get('pk', 'pedasi')
+        self.name = kwargs.get('name', 'PEDASI')
+
+        super().__init__(*args, **kwargs)
 
-    @staticmethod
-    def get_absolute_url():
+    def get_absolute_url(self):
         """
         Return the URL at which PEDASI is hosted.
         """
         # TODO don't hardcode URL
-        return 'http://www.pedasi-iot.org/'
+        return 'https://dev.iotobservatory.io/'
 
 
 @enum.unique
@@ -66,7 +67,7 @@ class ProvEntry(mongoengine.DynamicDocument):
     def create_prov(cls,
                     instance: BaseAppDataModel,
                     user_uri: str,
-                    application: typing.Optional[Application] = None,
+                    application: typing.Optional[ProvApplicationModel] = None,
                     activity_type: typing.Optional[ProvActivity] = ProvActivity.UPDATE) -> 'ProvEntry':
         """
         Build a PROV document representing a particular activity within PEDASI.
@@ -116,7 +117,7 @@ class ProvEntry(mongoengine.DynamicDocument):
         )
 
         if application is None:
-            application = PedasiDummyApplication
+            application = ProvApplicationModel()
 
         agent_application = document.agent(
             'piot:app-' + str(application.pk),
@@ -217,7 +218,7 @@ class ProvWrapper(mongoengine.Document):
     def create_prov(cls,
                     instance: BaseAppDataModel,
                     user_uri: str,
-                    application: typing.Optional[Application] = None,
+                    application: typing.Optional[ProvApplicationModel] = None,
                     activity_type: typing.Optional[ProvActivity] = ProvActivity.UPDATE) -> ProvEntry:
         """
         Create a PROV record for a single action.
@@ -251,15 +252,34 @@ class ProvWrapper(mongoengine.Document):
         super().delete(signal_kwargs, **write_concern)
 
 
-@receiver(signals.post_save, sender=Application)
-@receiver(signals.post_save, sender=DataSource)
-def save_prov(sender, instance, **kwargs):
+class ProvAbleModel:
     """
-    Signal receiver to create a :class:`ProvEntry` when a PROV tracked model is saved.
+    Mixin for models which are capable of having updates tracked by PROV records.
+
+    Creates a new PROV record every time the object is modified and saved.
     """
-    ProvWrapper.create_prov(
-        instance,
-        # TODO what if an admin edits a model?
-        instance.owner.get_uri(),
-        activity_type=ProvActivity.UPDATE
-    )
+    def save(self, *args, **kwargs):
+        try:
+            # Have to read existing saved version from database
+            existing = type(self).objects.get(pk=self.pk)
+            changed = False
+
+            for field in self._meta.fields:
+                attr = field.attname
+
+                if getattr(existing, attr) != getattr(self, attr):
+                    changed = True
+                    break
+
+        except type(self).DoesNotExist:
+            # First time this object has been saved
+            changed = True
+
+        super().save(*args, **kwargs)
+
+        if changed:
+            ProvWrapper.create_prov(
+                self,
+                self.owner.get_uri(),
+                activity_type=ProvActivity.UPDATE
+            )
diff --git a/provenance/tests.py b/provenance/tests.py
index 53e2bd6e460c47b29b32d2d3a8eabfadcabe2bb8..8ed6f443eb0ccb5001269e04b3883339c148a851 100644
--- a/provenance/tests.py
+++ b/provenance/tests.py
@@ -4,7 +4,6 @@ Tests for PROV tracking functionality and the models required to support it.
 
 import json
 import pathlib
-import unittest
 
 from django.conf import settings
 from django.contrib.auth import get_user_model
@@ -15,6 +14,7 @@ import jsonschema
 import mongoengine
 from mongoengine.queryset.visitor import Q
 
+from applications.models import Application
 from datasources.models import DataSource
 from provenance import models
 
@@ -134,19 +134,6 @@ class ProvWrapperTest(TestCase):
         # Another PROV record should be created when model is changed and saved
         self.assertEqual(self._count_prov(self.datasource), n_provs + 1)
 
-    @unittest.expectedFailure
-    def test_prov_datasource_no_update(self):
-        """
-        Test that a new :class:`ProvEntry` is not created when a model is saved without changes.
-        """
-        n_provs = self._count_prov(self.datasource)
-
-        self.datasource.save()
-
-        # Another PROV record should be created when model is changed and saved
-        self.assertEqual(self._count_prov(self.datasource), n_provs)
-
-    @unittest.expectedFailure
     def test_prov_datasource_null_update(self):
         """
         Test that no new :class:`ProvEntry` is created when a model is saved without changes.
@@ -173,3 +160,86 @@ class ProvWrapperTest(TestCase):
 
         intersection = set(prov_entries).intersection(new_prov_entries)
         self.assertFalse(intersection)
+
+
+class ProvApplicationTest(TestCase):
+    """
+    Test the wrapper that allows us to look up :class:`ProvEntry`s for a given Application.
+    """
+    model = Application
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user_model = get_user_model()
+        cls.user = cls.user_model.objects.create_user('Test Prov User')
+
+    def setUp(self):
+        self.object = self.model.objects.create(
+            name='Test Object',
+            url='http://www.example.com',
+            owner=self.user,
+        )
+
+    def tearDown(self):
+        # Have to delete instance manually since we're not using Django's database manager
+        object_type = ContentType.objects.get_for_model(self.model)
+
+        models.ProvWrapper.objects(
+            Q(app_label=object_type.app_label) &
+            Q(model_name=object_type.model) &
+            Q(related_pk=self.object.pk)
+        ).delete()
+
+    @staticmethod
+    def _count_prov(obj) -> int:
+        """
+        Count PROV records for a given object.
+        """
+        prov_entries = models.ProvWrapper.filter_model_instance(obj)
+        return prov_entries.count()
+
+    def test_prov_application_create(self):
+        """
+        Test that a :class:`ProvEntry` is created when a model is created.
+        """
+        # PROV record should be created when model is created
+        self.assertEqual(self._count_prov(self.object), 1)
+
+    def test_prov_application_update(self):
+        """
+        Test that a new :class:`ProvEntry` is created when a model is updated.
+        """
+        n_provs = self._count_prov(self.object)
+
+        self.object.url = 'http://example.com'
+        self.object.save()
+
+        # Another PROV record should be created when model is changed and saved
+        self.assertEqual(self._count_prov(self.object), n_provs + 1)
+
+    def test_prov_application_null_update(self):
+        """
+        Test that no new :class:`ProvEntry` is created when a model is saved without changes.
+        """
+        n_provs = self._count_prov(self.object)
+
+        self.object.save()
+
+        # No PROV record should be created when saving a model that has not changed
+        self.assertEqual(self._count_prov(self.object), n_provs)
+
+    def test_prov_records_distinct(self):
+        """
+        Test that :class:`ProvEntry`s are not reused.
+        """
+        prov_entries = models.ProvWrapper.filter_model_instance(self.object)
+
+        new_object = self.model.objects.create(
+            name='Another Test Object',
+            url='http://www.example.com',
+            owner=self.user,
+        )
+        new_prov_entries = models.ProvWrapper.filter_model_instance(new_object)
+
+        intersection = set(prov_entries).intersection(new_prov_entries)
+        self.assertFalse(intersection)