Ver código fonte

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 anos atrás
pai
commit
ff00555681

+ 53 - 68
api/desecapi/models.py

@@ -121,9 +121,18 @@ class User(AbstractBaseUser):
         return self.is_admin
 
     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.save()
 
@@ -165,58 +174,44 @@ class Domain(models.Model, mixins.SetterMixin):
 
         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
     def sync_from_pdns(self):
@@ -339,24 +334,17 @@ class Domain(models.Model, mixins.SetterMixin):
                                 if rrs and rrset in rrsets_with_new_rrs
                                 for rr in rrs])
 
+        # Update published timestamp on domain
+        self.published = timezone.now()
+        self.save()
+
         # 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 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
     def delete(self, *args, **kwargs):
         # Delete delegation for dynDNS domains (direct child of dedyn.io)
@@ -382,7 +370,7 @@ class Domain(models.Model, mixins.SetterMixin):
         super().save(*args, **kwargs)
 
         if new and not self.owner.locked:
-            self.sync_to_pdns()
+            self.create_on_pdns()
 
     def __str__(self):
         """
@@ -500,9 +488,6 @@ class RRset(models.Model, mixins.SetterMixin):
 
     @transaction.atomic
     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._dirties = {}
 

+ 26 - 0
api/desecapi/permissions.py

@@ -18,3 +18,29 @@ class IsDomainOwner(permissions.BasePermission):
     def has_object_permission(self, request, view, obj):
         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
 from django.conf import settings
 import json
+from django.utils import timezone
+from desecapi.exceptions import PdnsException
 
 
 class UnauthenticatedDomainTests(APITestCase):
@@ -181,6 +183,41 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         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):
         url = reverse('v1:domain-list')
         data = {'name': 'very.long.domain.name.' + utils.generateDomainname()}
@@ -358,3 +395,51 @@ class AuthenticatedDynDomainTests(APITestCase):
             response = self.client.post(url, data)
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
             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
 from django.conf import settings
 import json
-from django.utils import timezone
-from desecapi.exceptions import PdnsException
 
 
 class DynDNS12UpdateTest(APITestCase):
@@ -249,102 +247,3 @@ class DynDNS12UpdateTest(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         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.data['records'][0], '1.2.3.4')
 
-    def testCantDeleteOwnRRsetWhileAccountIsLocked(self):
+    def testCantCreateRRsetWhileAccountIsLocked(self):
         self.owner.locked = timezone.now()
         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,))
         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_201_CREATED)
 
+        self.owner.locked = timezone.now()
+        self.owner.save()
+
         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': []}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         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 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 (
     DomainSerializer, RRsetSerializer, DonationSerializer, TokenSerializer)
 from rest_framework import generics
-from desecapi.permissions import IsOwner, IsDomainOwner
+from desecapi.permissions import *
 from rest_framework import permissions
 from django.http import Http404, HttpResponseRedirect
 from rest_framework.views import APIView
@@ -84,7 +84,7 @@ class TokenViewSet(mixins.CreateModelMixin,
 
     def destroy(self, request, *args, **kwargs):
         try:
-            super().destroy(self, request, *args, **kwargs)
+            super().destroy(request, *args, **kwargs)
         except Http404:
             pass
         return Response(status=status.HTTP_204_NO_CONTENT)
@@ -95,7 +95,7 @@ class TokenViewSet(mixins.CreateModelMixin,
 
 class DomainList(generics.ListCreateAPIView):
     serializer_class = DomainSerializer
-    permission_classes = (permissions.IsAuthenticated, IsOwner,)
+    permission_classes = (permissions.IsAuthenticated, IsOwner, IsUnlockedOrDyn,)
 
     def get_queryset(self):
         return Domain.objects.filter(owner=self.request.user.pk)
@@ -159,7 +159,7 @@ class DomainList(generics.ListCreateAPIView):
 
 class DomainDetail(generics.RetrieveUpdateDestroyAPIView):
     serializer_class = DomainSerializer
-    permission_classes = (permissions.IsAuthenticated, IsOwner,)
+    permission_classes = (permissions.IsAuthenticated, IsOwner, IsUnlocked,)
     lookup_field = 'name'
 
     def delete(self, request, *args, **kwargs):
@@ -184,7 +184,7 @@ class DomainDetail(generics.RetrieveUpdateDestroyAPIView):
 class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
     lookup_field = 'type'
     serializer_class = RRsetSerializer
-    permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
+    permission_classes = (permissions.IsAuthenticated, IsDomainOwner, IsUnlocked,)
 
     def dispatch(self, request, *args, **kwargs):
         if kwargs['subname'] == '@':
@@ -192,9 +192,6 @@ class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
         return super().dispatch(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:
             super().delete(request, *args, **kwargs)
         except Http404:
@@ -237,7 +234,7 @@ class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
 
 class RRsetList(ListBulkCreateUpdateAPIView):
     serializer_class = RRsetSerializer
-    permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
+    permission_classes = (permissions.IsAuthenticated, IsDomainOwner, IsUnlocked,)
 
     def get_queryset(self):
         name = self.kwargs['name']
@@ -357,6 +354,10 @@ class DynDNS12Update(APIView):
     renderer_classes = [PlainTextRenderer]
 
     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):
             # 1. hostname parameter
             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
 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
 recommend solving the captcha using some additional privacy measures such as an
 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
@@ -177,8 +177,8 @@ Field details:
 ``locked``
     :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

+ 1 - 3
docs/domains.rst

@@ -78,9 +78,7 @@ Field details:
 
     As we publish record modifications immediately, this indicates the
     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