Browse Source

feature(api): remove write-support for locked users, closes #147

This removes all write-support for locked users.  Exception: Users
which have the dyn flag set can created domains under .dedyn.io
(but they will not be published until the account is unlocked).
Peter Thomassen 6 years ago
parent
commit
ff00555681

+ 53 - 68
api/desecapi/models.py

@@ -121,9 +121,18 @@ class User(AbstractBaseUser):
         return self.is_admin
         return self.is_admin
 
 
     def unlock(self):
     def unlock(self):
-        # self.locked is used by domain.sync_to_pdns(), so call that first
-        for domain in self.domains.all():
-            domain.sync_to_pdns()
+        if self.locked is None:
+            return
+
+        # Create domains on pdns that were created after the account was locked.
+        # Those are obtained using created__gt=self.locked.
+        # Using published=None gives the same result at the time of writing this
+        # comment, but it is not semantically the same. If there ever will be
+        # unpublished domains that are older than the lock, they are not created.
+        for domain in self.domains.filter(created__gt=self.locked):
+            domain.create_on_pdns()
+
+        # Unlock
         self.locked = None
         self.locked = None
         self.save()
         self.save()
 
 
@@ -165,58 +174,44 @@ class Domain(models.Model, mixins.SetterMixin):
 
 
         return name
         return name
 
 
-    def sync_to_pdns(self):
+    # This method does not use @transaction.atomic as this could lead to
+    # orphaned zones on pdns.
+    def create_on_pdns(self):
         """
         """
-        Make sure that pdns gets the latest information about this domain/zone.
-        Re-syncing is relatively expensive and should not happen routinely.
+        Create zone on pdns
 
 
-        This method should only be called for new domains or on user unlocking.
-        For unlocked users, it assumes that the domain is a new one.
+        This method should only be called for new domains when they are created,
+        or when the domain was created with a locked account and not yet propagated.
         """
         """
 
 
-        # Determine if this domain is expected to be new on pdns. This is the
-        # case if the user is not locked (by assumption) or if the domain was
-        # created after the user was locked. (If the user had this domain
-        # before locking, it is not new on pdns.)
-        new = self.owner.locked is None or self.owner.locked < self.created
-
-        if new:
-            # Create zone
-            # Throws exception if pdns already knows this zone for some reason
-            # which means that it is not ours and we should not mess with it.
-            # We escalate the exception to let the next level deal with the
-            # response.
-            pdns.create_zone(self, settings.DEFAULT_NS)
-
-            # Send RRsets to pdns that may have been created (e.g. during lock).
-            self._publish()
-
-            # Make our RRsets consistent with pdns (specifically, NS may exist)
-            self.sync_from_pdns()
-
-            # For dedyn.io domains, propagate NS and DS delegation RRsets
-            subname, parent_pdns_id = self.pdns_id.split('.', 1)
-            if parent_pdns_id == 'dedyn.io.':
-                try:
-                    parent = Domain.objects.get(name='dedyn.io')
-                except Domain.DoesNotExist:
-                    pass
-                else:
-                    rrsets = RRset.plain_to_RRsets([
-                        {'subname': subname, 'type': 'NS', 'ttl': 3600,
-                         'contents': settings.DEFAULT_NS},
-                        {'subname': subname, 'type': 'DS', 'ttl': 60,
-                         'contents': [ds for k in self.keys for ds in k['ds']]}
-                    ], domain=parent)
-                    parent.write_rrsets(rrsets)
-        else:
-            # Zone exists. For the case that pdns knows records that we do not
-            # (e.g. if a locked account has deleted an RRset), it is necessary
-            # to purge all records here. However, there is currently no way to
-            # do this through the pdns API (not to mention doing it atomically
-            # with setting the new RRsets). So for now, we have disabled RRset
-            # deletion for locked accounts.
-            self._publish()
+        # Throws exception if pdns already knows this zone for some reason
+        # which means that it is not ours and we should not mess with it.
+        # We escalate the exception to let the next level deal with the
+        # response.
+        pdns.create_zone(self, settings.DEFAULT_NS)
+
+        # Update published timestamp on domain
+        self.published = timezone.now()
+        self.save()
+
+        # Make our RRsets consistent with pdns (specifically, NS may exist)
+        self.sync_from_pdns()
+
+        # For dedyn.io domains, propagate NS and DS delegation RRsets
+        subname, parent_pdns_id = self.pdns_id.split('.', 1)
+        if parent_pdns_id == 'dedyn.io.':
+            try:
+                parent = Domain.objects.get(name='dedyn.io')
+            except Domain.DoesNotExist:
+                pass
+            else:
+                rrsets = RRset.plain_to_RRsets([
+                    {'subname': subname, 'type': 'NS', 'ttl': 3600,
+                     'contents': settings.DEFAULT_NS},
+                    {'subname': subname, 'type': 'DS', 'ttl': 60,
+                     'contents': [ds for k in self.keys for ds in k['ds']]}
+                ], domain=parent)
+                parent.write_rrsets(rrsets)
 
 
     @transaction.atomic
     @transaction.atomic
     def sync_from_pdns(self):
     def sync_from_pdns(self):
@@ -339,24 +334,17 @@ class Domain(models.Model, mixins.SetterMixin):
                                 if rrs and rrset in rrsets_with_new_rrs
                                 if rrs and rrset in rrsets_with_new_rrs
                                 for rr in rrs])
                                 for rr in rrs])
 
 
+        # Update published timestamp on domain
+        self.published = timezone.now()
+        self.save()
+
         # Send RRsets to pdns
         # Send RRsets to pdns
-        if not self.owner.locked:
-            self._publish(rrsets_for_pdns)
+        if rrsets_for_pdns:
+            pdns.set_rrsets(self, rrsets_for_pdns)
 
 
         # Return RRsets
         # Return RRsets
         return list(rrsets_to_return.values())
         return list(rrsets_to_return.values())
 
 
-    @transaction.atomic
-    def _publish(self, rrsets = None):
-        if rrsets is None:
-            rrsets = self.rrset_set.all()
-
-        self.published = timezone.now()
-        self.save()
-
-        if rrsets:
-            pdns.set_rrsets(self, rrsets)
-
     @transaction.atomic
     @transaction.atomic
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
         # Delete delegation for dynDNS domains (direct child of dedyn.io)
         # Delete delegation for dynDNS domains (direct child of dedyn.io)
@@ -382,7 +370,7 @@ class Domain(models.Model, mixins.SetterMixin):
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
         if new and not self.owner.locked:
         if new and not self.owner.locked:
-            self.sync_to_pdns()
+            self.create_on_pdns()
 
 
     def __str__(self):
     def __str__(self):
         """
         """
@@ -500,9 +488,6 @@ class RRset(models.Model, mixins.SetterMixin):
 
 
     @transaction.atomic
     @transaction.atomic
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
-        # For locked users, we can't easily sync deleted RRsets to pdns later,
-        # so let's forbid it for now.
-        assert not self.domain.owner.locked
         self.domain.write_rrsets({self: []})
         self.domain.write_rrsets({self: []})
         self._dirties = {}
         self._dirties = {}
 
 

+ 26 - 0
api/desecapi/permissions.py

@@ -18,3 +18,29 @@ class IsDomainOwner(permissions.BasePermission):
     def has_object_permission(self, request, view, obj):
     def has_object_permission(self, request, view, obj):
         return obj.domain.owner == request.user
         return obj.domain.owner == request.user
 
 
+
+class IsUnlocked(permissions.BasePermission):
+    """
+    Allow non-safe methods only when account is not locked.
+    """
+    message = 'You cannot modify DNS data while your account is locked.'
+
+    def has_permission(self, request, view):
+        return bool(
+            request.method in permissions.SAFE_METHODS or
+            not request.user.locked
+        )
+
+
+class IsUnlockedOrDyn(permissions.BasePermission):
+    """
+    Allow non-safe methods only for unlocked or dynDNS users.
+    """
+    message = IsUnlocked.message
+
+    def has_permission(self, request, view):
+        return bool(
+            request.method in permissions.SAFE_METHODS or
+            request.user.dyn or
+            not request.user.locked
+        )

+ 85 - 0
api/desecapi/tests/testdomains.py

@@ -7,6 +7,8 @@ from django.core import mail
 import httpretty
 import httpretty
 from django.conf import settings
 from django.conf import settings
 import json
 import json
+from django.utils import timezone
+from desecapi.exceptions import PdnsException
 
 
 
 
 class UnauthenticatedDomainTests(APITestCase):
 class UnauthenticatedDomainTests(APITestCase):
@@ -181,6 +183,41 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertTrue("does not match the required pattern." in response.data['name'][0])
         self.assertTrue("does not match the required pattern." in response.data['name'][0])
 
 
+    def testCantPostDomainsWhenAccountIsLocked(self):
+        # Lock user
+        self.owner.locked = timezone.now()
+        self.owner.save()
+
+        url = reverse('v1:domain-list')
+        data = {'name': utils.generateDomainname()}
+        response = self.client.post(url, data)
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def testCantModifyDomainsWhenAccountIsLocked(self):
+        name = utils.generateDomainname()
+        data = {'name': name}
+        url = reverse('v1:domain-list')
+        self.client.post(url, data)
+
+        # Lock user
+        self.owner.locked = timezone.now()
+        self.owner.save()
+
+        url = reverse('v1:domain-detail', args=(name,))
+        data = {'name': 'test.de'}
+
+        # PATCH
+        response = self.client.patch(url, data)
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        # PUT
+        response = self.client.put(url, data)
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        # DELETE
+        response = self.client.put(url)
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
     def testCanPostComplicatedDomains(self):
     def testCanPostComplicatedDomains(self):
         url = reverse('v1:domain-list')
         url = reverse('v1:domain-list')
         data = {'name': 'very.long.domain.name.' + utils.generateDomainname()}
         data = {'name': 'very.long.domain.name.' + utils.generateDomainname()}
@@ -358,3 +395,51 @@ class AuthenticatedDynDomainTests(APITestCase):
             response = self.client.post(url, data)
             response = self.client.post(url, data)
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
             self.assertEqual(len(mail.outbox), outboxlen)
             self.assertEqual(len(mail.outbox), outboxlen)
+
+    def testDomainCreationUponUnlockingLockedAccount(self):
+        # Lock user
+        self.owner.locked = timezone.now()
+        self.owner.save()
+
+        newdomain = utils.generateDynDomainname()
+        data = {'name': newdomain}
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET,
+                               settings.NSLORD_PDNS_API + '/zones/' + newdomain + './cryptokeys',
+                               body='[]',
+                               content_type="application/json")
+
+        url = reverse('v1:domain-list')
+
+        # Dyn users should be able to create domains under dedyn.io even when locked
+        response = self.client.post(url, data)
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        # See what happens upon unlock if pdns knows this domain already
+        httpretty.register_uri(httpretty.POST,
+                               settings.NSLORD_PDNS_API + '/zones',
+                               body='{"error": "Domain \'' + newdomain + '.\' already exists"}',
+                               status=422)
+
+        with self.assertRaises(PdnsException) as cm:
+            self.owner.unlock()
+
+        self.assertEqual(str(cm.exception),
+                         "Domain '" + newdomain + ".' already exists")
+
+        # See what happens upon unlock if this domain is new to pdns
+        httpretty.register_uri(httpretty.POST,
+                               settings.NSLORD_PDNS_API + '/zones')
+
+        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.')
+        httpretty.register_uri(httpretty.GET,
+                               settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.',
+                               body='{"rrsets": [{"comments": [], "name": "%s.", "records": [ { "content": "ns1.desec.io.", "disabled": false }, { "content": "ns2.desec.io.", "disabled": false } ], "ttl": 60, "type": "NS"}]}' % newdomain,
+                               content_type="application/json")
+        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + newdomain + './notify', status=200)
+
+        self.owner.unlock()
+
+        self.assertEqual(httpretty.httpretty.latest_requests[-3].method, 'POST')
+        self.assertTrue((settings.NSLORD_PDNS_API + '/zones').endswith(httpretty.httpretty.latest_requests[-3].path))

+ 0 - 101
api/desecapi/tests/testdyndns12update.py

@@ -6,8 +6,6 @@ import base64
 import httpretty
 import httpretty
 from django.conf import settings
 from django.conf import settings
 import json
 import json
-from django.utils import timezone
-from desecapi.exceptions import PdnsException
 
 
 
 
 class DynDNS12UpdateTest(APITestCase):
 class DynDNS12UpdateTest(APITestCase):
@@ -249,102 +247,3 @@ class DynDNS12UpdateTest(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='127.0.0.1')
         self.assertIP(ipv4='127.0.0.1')
-
-    def testSuspendedUpdates(self):
-        self.owner.locked = timezone.now()
-        self.owner.save()
-
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url,
-                                   {
-                                       'system': 'dyndns',
-                                       'hostname': self.domain,
-                                       'myip': '10.1.1.1'
-                                   })
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertIP(ipv4='10.1.1.1')
-
-        httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
-        httpretty.register_uri(httpretty.POST,
-                               settings.NSLORD_PDNS_API + '/zones',
-                               body='{"error": "Domain \'%s.\' already exists"}' % self.domain,
-                               content_type="application/json", status=422)
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.',
-                               body='{"rrsets": []}',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.domain + './notify', status=200)
-
-        self.owner.unlock()
-
-        self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'PATCH')
-        self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
-        self.assertTrue(self.domain in httpretty.httpretty.latest_requests[-2].parsed_body)
-        self.assertTrue('10.1.1.1' in httpretty.httpretty.latest_requests[-2].parsed_body)
-
-    def testSuspendedUpdatesDomainCreation(self):
-        # Lock user
-        self.owner.locked = timezone.now()
-        self.owner.save()
-
-        # While in locked state, create a domain and set some records
-        url = reverse('v1:domain-list')
-        newdomain = utils.generateDynDomainname()
-
-        data = {'name': newdomain}
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + newdomain + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url,
-                                   {
-                                       'system': 'dyndns',
-                                       'hostname': newdomain,
-                                       'myip': '10.2.2.2'
-                                   })
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertIP(name=newdomain, ipv4='10.2.2.2')
-
-        # See what happens upon unlock if pdns knows this domain already
-        httpretty.register_uri(httpretty.POST,
-                               settings.NSLORD_PDNS_API + '/zones',
-                               body='{"error": "Domain \'' + newdomain + '.\' already exists"}',
-                               status=422)
-
-        with self.assertRaises(PdnsException) as cm:
-            self.owner.unlock()
-
-        self.assertEqual(str(cm.exception),
-                         "Domain '" + newdomain + ".' already exists")
-
-        # See what happens upon unlock if this domain is new to pdns
-        httpretty.register_uri(httpretty.POST,
-                               settings.NSLORD_PDNS_API + '/zones')
-
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.',
-                               body='{"rrsets": [{"comments": [], "name": "%s.", "records": [ { "content": "ns1.desec.io.", "disabled": false }, { "content": "ns2.desec.io.", "disabled": false } ], "ttl": 60, "type": "NS"}]}' % self.domain,
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + newdomain + './notify', status=200)
-
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.',
-                               body='{"rrsets": [{"comments": [], "name": "%s.", "records": [ { "content": "ns1.desec.io.", "disabled": false }, { "content": "ns2.desec.io.", "disabled": false } ], "ttl": 60, "type": "NS"}]}' % self.domain,
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.domain + './notify', status=200)
-
-        self.owner.unlock()
-
-        self.assertEqual(httpretty.httpretty.latest_requests[-5].method, 'POST')
-        self.assertTrue((settings.NSLORD_PDNS_API + '/zones').endswith(httpretty.httpretty.latest_requests[-5].path))
-        self.assertEqual(httpretty.httpretty.latest_requests[-3].method, 'PATCH')
-        self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.').endswith(httpretty.httpretty.latest_requests[-3].path))
-        self.assertTrue('10.2.2.2' in httpretty.httpretty.latest_requests[-3].parsed_body)

+ 21 - 2
api/desecapi/tests/testrrsets.py

@@ -575,18 +575,37 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
         self.assertEqual(response.data['records'][0], '1.2.3.4')
 
 
-    def testCantDeleteOwnRRsetWhileAccountIsLocked(self):
+    def testCantCreateRRsetWhileAccountIsLocked(self):
         self.owner.locked = timezone.now()
         self.owner.locked = timezone.now()
         self.owner.save()
         self.owner.save()
 
 
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def testCantModifyRRsetWhileAccountIsLocked(self):
         url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
+        self.owner.locked = timezone.now()
+        self.owner.save()
+
         url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
 
 
-        # Try PATCH with empty records
+        # Try PUT
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        # Try PATCH
+        data = {'records': ['4.3.2.1']}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        # Try PATCH to delete
         data = {'records': []}
         data = {'records': []}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

+ 11 - 10
api/desecapi/views.py

@@ -1,10 +1,10 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 from django.core.mail import EmailMessage
 from django.core.mail import EmailMessage
-from desecapi.models import Domain, User, RRset, RR, Token
+from desecapi.models import Domain, User, RRset, Token
 from desecapi.serializers import (
 from desecapi.serializers import (
     DomainSerializer, RRsetSerializer, DonationSerializer, TokenSerializer)
     DomainSerializer, RRsetSerializer, DonationSerializer, TokenSerializer)
 from rest_framework import generics
 from rest_framework import generics
-from desecapi.permissions import IsOwner, IsDomainOwner
+from desecapi.permissions import *
 from rest_framework import permissions
 from rest_framework import permissions
 from django.http import Http404, HttpResponseRedirect
 from django.http import Http404, HttpResponseRedirect
 from rest_framework.views import APIView
 from rest_framework.views import APIView
@@ -84,7 +84,7 @@ class TokenViewSet(mixins.CreateModelMixin,
 
 
     def destroy(self, request, *args, **kwargs):
     def destroy(self, request, *args, **kwargs):
         try:
         try:
-            super().destroy(self, request, *args, **kwargs)
+            super().destroy(request, *args, **kwargs)
         except Http404:
         except Http404:
             pass
             pass
         return Response(status=status.HTTP_204_NO_CONTENT)
         return Response(status=status.HTTP_204_NO_CONTENT)
@@ -95,7 +95,7 @@ class TokenViewSet(mixins.CreateModelMixin,
 
 
 class DomainList(generics.ListCreateAPIView):
 class DomainList(generics.ListCreateAPIView):
     serializer_class = DomainSerializer
     serializer_class = DomainSerializer
-    permission_classes = (permissions.IsAuthenticated, IsOwner,)
+    permission_classes = (permissions.IsAuthenticated, IsOwner, IsUnlockedOrDyn,)
 
 
     def get_queryset(self):
     def get_queryset(self):
         return Domain.objects.filter(owner=self.request.user.pk)
         return Domain.objects.filter(owner=self.request.user.pk)
@@ -159,7 +159,7 @@ class DomainList(generics.ListCreateAPIView):
 
 
 class DomainDetail(generics.RetrieveUpdateDestroyAPIView):
 class DomainDetail(generics.RetrieveUpdateDestroyAPIView):
     serializer_class = DomainSerializer
     serializer_class = DomainSerializer
-    permission_classes = (permissions.IsAuthenticated, IsOwner,)
+    permission_classes = (permissions.IsAuthenticated, IsOwner, IsUnlocked,)
     lookup_field = 'name'
     lookup_field = 'name'
 
 
     def delete(self, request, *args, **kwargs):
     def delete(self, request, *args, **kwargs):
@@ -184,7 +184,7 @@ class DomainDetail(generics.RetrieveUpdateDestroyAPIView):
 class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
 class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
     lookup_field = 'type'
     lookup_field = 'type'
     serializer_class = RRsetSerializer
     serializer_class = RRsetSerializer
-    permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
+    permission_classes = (permissions.IsAuthenticated, IsDomainOwner, IsUnlocked,)
 
 
     def dispatch(self, request, *args, **kwargs):
     def dispatch(self, request, *args, **kwargs):
         if kwargs['subname'] == '@':
         if kwargs['subname'] == '@':
@@ -192,9 +192,6 @@ class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
         return super().dispatch(request, *args, **kwargs)
         return super().dispatch(request, *args, **kwargs)
 
 
     def delete(self, request, *args, **kwargs):
     def delete(self, request, *args, **kwargs):
-        if request.user.locked:
-            detail = "You cannot delete RRsets while your account is locked."
-            raise PermissionDenied(detail)
         try:
         try:
             super().delete(request, *args, **kwargs)
             super().delete(request, *args, **kwargs)
         except Http404:
         except Http404:
@@ -237,7 +234,7 @@ class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
 
 
 class RRsetList(ListBulkCreateUpdateAPIView):
 class RRsetList(ListBulkCreateUpdateAPIView):
     serializer_class = RRsetSerializer
     serializer_class = RRsetSerializer
-    permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
+    permission_classes = (permissions.IsAuthenticated, IsDomainOwner, IsUnlocked,)
 
 
     def get_queryset(self):
     def get_queryset(self):
         name = self.kwargs['name']
         name = self.kwargs['name']
@@ -357,6 +354,10 @@ class DynDNS12Update(APIView):
     renderer_classes = [PlainTextRenderer]
     renderer_classes = [PlainTextRenderer]
 
 
     def findDomain(self, request):
     def findDomain(self, request):
+        if self.request.user.locked:
+            # Error code from https://help.dyn.com/remote-access-api/return-codes/
+            raise PermissionDenied('abuse')
+
         def findDomainname(request):
         def findDomainname(request):
             # 1. hostname parameter
             # 1. hostname parameter
             if 'hostname' in request.query_params and request.query_params['hostname'] != 'YES':
             if 'hostname' in request.query_params and request.query_params['hostname'] != 'YES':

+ 5 - 5
docs/authentication.rst

@@ -85,12 +85,12 @@ Preventing Abuse
 
 
 We enforce some limits on user creation requests to make abuse harder. In cases
 We enforce some limits on user creation requests to make abuse harder. In cases
 where our heuristic suspects abuse, the server will still reply with
 where our heuristic suspects abuse, the server will still reply with
-``201 Created`` but will send you an email asking to solve a
+``201 Created`` but will send you an (additional) email asking to solve a
 Google ReCaptcha. We implemented this as privacy-friendly as possible, but
 Google ReCaptcha. We implemented this as privacy-friendly as possible, but
 recommend solving the captcha using some additional privacy measures such as an
 recommend solving the captcha using some additional privacy measures such as an
 anonymous browser-tab, VPN, etc. Before solving the captcha, the account will
 anonymous browser-tab, VPN, etc. Before solving the captcha, the account will
-be on hold, that is, it will be possible to log in and issue most requests as
-normal; however, any DNS settings will not be deployed to our servers.
+be locked, that is, it will be possible to log in; however, most operations on
+the API will be limited to read-only.
 
 
 
 
 Log In
 Log In
@@ -177,8 +177,8 @@ Field details:
 ``locked``
 ``locked``
     :Access mode: read-only
     :Access mode: read-only
 
 
-    Indicates whether the account is locked.  If so, publication of DNS
-    record changes will be adjourned.
+    Indicates whether the account is locked.  If so, domains put in
+    read-only mode.  Changes are not propagated in the DNS system.
 
 
 
 
 Retrieve Account Information
 Retrieve Account Information

+ 1 - 3
docs/domains.rst

@@ -78,9 +78,7 @@ Field details:
 
 
     As we publish record modifications immediately, this indicates the
     As we publish record modifications immediately, this indicates the
     point in time of the last successful write request to a domain's
     point in time of the last successful write request to a domain's
-    ``rrsets/`` endpoint.  Exception: If the user account is locked, record
-    changes are queued and not published immediately. In this case, the
-    ``published`` field is not updated.
+    ``rrsets/`` endpoint.
 
 
 
 
 Creating a Domain
 Creating a Domain