瀏覽代碼

feat(api): add token domain policy

Peter Thomassen 4 年之前
父節點
當前提交
5233cceb56

+ 1 - 0
api/api/settings.py

@@ -47,6 +47,7 @@ INSTALLED_APPS = (
     'desecapi.apps.AppConfig',
     'corsheaders',
     'django_prometheus',
+    'pgtrigger',
 )
 
 MIDDLEWARE = (

+ 64 - 0
api/desecapi/migrations/0018_tokendomainpolicy.py

@@ -0,0 +1,64 @@
+# Generated by Django 3.2.10 on 2021-12-17 22:56
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.db.models.expressions
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0017_alter_user_limit_domains'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='token',
+            name='user',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.CreateModel(
+            name='TokenDomainPolicy',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('perm_dyndns', models.BooleanField(default=False)),
+                ('perm_rrsets', models.BooleanField(default=False)),
+                ('domain', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='desecapi.domain')),
+                ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='desecapi.token')),
+                ('token_user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='token',
+            name='domain_policies',
+            field=models.ManyToManyField(through='desecapi.TokenDomainPolicy', to='desecapi.Domain'),
+        ),
+        migrations.AddConstraint(
+            model_name='tokendomainpolicy',
+            constraint=models.UniqueConstraint(fields=('token', 'domain'), name='unique_entry'),
+        ),
+        migrations.AddConstraint(
+            model_name='tokendomainpolicy',
+            constraint=models.UniqueConstraint(condition=models.Q(('domain__isnull', True)), fields=('token',), name='unique_entry_null_domain'),
+        ),
+        # The remaining operations ensure that domain.owner and token.user can't be inconsistent
+        migrations.AlterModelOptions(
+            name='token',
+            options={},
+        ),
+        migrations.AddConstraint(
+            model_name='token',
+            constraint=models.UniqueConstraint(fields=('id', 'user'), name='unique_id_user'),
+        ),
+        migrations.AddConstraint(
+            model_name='domain',
+            constraint=models.UniqueConstraint(fields=('id', 'owner'), name='unique_id_owner'),
+        ),
+        migrations.RunSQL(
+           "ALTER TABLE desecapi_tokendomainpolicy"
+           " ADD FOREIGN KEY ( domain_id, token_user_id ) REFERENCES desecapi_domain ( id, owner_id ),"
+           " ADD FOREIGN KEY ( token_id, token_user_id ) REFERENCES desecapi_token ( id, user_id );",
+           migrations.RunSQL.noop
+        ),
+    ]

+ 95 - 4
api/desecapi/models.py

@@ -14,6 +14,7 @@ from functools import cached_property
 from hashlib import sha256
 
 import dns
+import pgtrigger
 import psl_dns
 import rest_framework.authtoken.models
 from django.conf import settings
@@ -24,7 +25,7 @@ from django.contrib.postgres.fields import ArrayField, CIEmailField, RangeOperat
 from django.core.exceptions import ValidationError
 from django.core.mail import EmailMessage, get_connection
 from django.core.validators import MinValueValidator, RegexValidator
-from django.db import models
+from django.db import models, transaction
 from django.db.models import CharField, F, Manager, Q, Value
 from django.db.models.expressions import RawSQL
 from django.db.models.functions import Concat, Length
@@ -244,6 +245,10 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
     _keys = None
     objects = DomainManager()
 
+    class Meta:
+        constraints = [models.UniqueConstraint(fields=['id', 'owner'], name='unique_id_owner')]
+        ordering = ('created',)
+
     def __init__(self, *args, **kwargs):
         if isinstance(kwargs.get('owner'), AnonymousUser):
             kwargs = {**kwargs, 'owner': None}  # make a copy and override
@@ -403,9 +408,6 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
     def __str__(self):
         return self.name
 
-    class Meta:
-        ordering = ('created',)
-
 
 class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models.Token):
     @staticmethod
@@ -421,10 +423,14 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
     allowed_subnets = ArrayField(CidrAddressField(), default=_allowed_subnets_default.__func__)
     max_age = models.DurationField(null=True, default=None, validators=[MinValueValidator(timedelta(0))])
     max_unused_period = models.DurationField(null=True, default=None, validators=[MinValueValidator(timedelta(0))])
+    domain_policies = models.ManyToManyField(Domain, through='TokenDomainPolicy')
 
     plain = None
     objects = NetManager()
 
+    class Meta:
+        constraints = [models.UniqueConstraint(fields=['id', 'user'], name='unique_id_user')]
+
     @property
     def is_valid(self):
         now = timezone.now()
@@ -454,6 +460,91 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
     def make_hash(plain):
         return make_password(plain, salt='static', hasher='pbkdf2_sha256_iter1')
 
+    def get_policy(self, *, domain=None):
+        order_by = F('domain').asc(nulls_last=True)  # default Postgres sorting, but: explicit is better than implicit
+        return self.tokendomainpolicy_set.filter(Q(domain=domain) | Q(domain__isnull=True)).order_by(order_by).first()
+
+    @transaction.atomic
+    def delete(self):
+        # This is needed because Model.delete() emulates cascade delete via django.db.models.deletion.Collector.delete()
+        # which deletes related objects in pk order.  However, the default policy has to be deleted last.
+        # Perhaps this will change with https://code.djangoproject.com/ticket/21961
+        self.tokendomainpolicy_set.filter(domain__isnull=False).delete()
+        self.tokendomainpolicy_set.filter(domain__isnull=True).delete()
+        return super().delete()
+
+
+@pgtrigger.register(
+    # Ensure that token_user is consistent with token
+    pgtrigger.Trigger(
+        name='token_user',
+        operation=pgtrigger.Update | pgtrigger.Insert,
+        when=pgtrigger.Before,
+        func='NEW.token_user_id = (SELECT user_id FROM desecapi_token WHERE id = NEW.token_id); RETURN NEW;',
+    ),
+
+    # Ensure that if there is *any* domain policy for a given token, there is always one with domain=None.
+    pgtrigger.Trigger(
+        name='default_policy_on_insert',
+        operation=pgtrigger.Insert,
+        when=pgtrigger.Before,
+        # Trigger `condition` arguments (corresponding to WHEN clause) don't support subqueries, so we use `func`
+        func="IF (NEW.domain_id IS NOT NULL and NOT EXISTS(SELECT * FROM desecapi_tokendomainpolicy WHERE domain_id IS NULL AND token_id = NEW.token_id)) THEN "
+             "  RAISE EXCEPTION 'Cannot insert non-default policy into % table when default policy is not present', TG_TABLE_NAME; "
+             "END IF; RETURN NEW;",
+    ),
+    pgtrigger.Protect(
+        name='default_policy_on_update',
+        operation=pgtrigger.Update,
+        when=pgtrigger.Before,
+        condition=pgtrigger.Q(old__domain__isnull=True, new__domain__isnull=False),
+    ),
+    # Ideally, this would be a deferred trigger, but depends on https://github.com/Opus10/django-pgtrigger/issues/14
+    pgtrigger.Trigger(
+        name='default_policy_on_delete',
+        operation=pgtrigger.Delete,
+        when=pgtrigger.Before,
+        # Trigger `condition` arguments (corresponding to WHEN clause) don't support subqueries, so we use `func`
+        func="IF (OLD.domain_id IS NULL and EXISTS(SELECT * FROM desecapi_tokendomainpolicy WHERE domain_id IS NOT NULL AND token_id = OLD.token_id)) THEN "
+             "  RAISE EXCEPTION 'Cannot delete default policy from % table when non-default policy is present', TG_TABLE_NAME; "
+             "END IF; RETURN OLD;",
+    ),
+)
+class TokenDomainPolicy(ExportModelOperationsMixin('TokenDomainPolicy'), models.Model):
+    token = models.ForeignKey(Token, on_delete=models.CASCADE)
+    domain = models.ForeignKey(Domain, on_delete=models.CASCADE, null=True)
+    perm_dyndns = models.BooleanField(default=False)
+    perm_rrsets = models.BooleanField(default=False)
+    # Token user, filled via trigger. Used by compound FK constraints to tie domain.owner to token.user (see migration).
+    token_user = models.ForeignKey(User, on_delete=models.CASCADE, db_constraint=False, related_name='+')
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(fields=['token', 'domain'], name='unique_entry'),
+            models.UniqueConstraint(fields=['token'], condition=Q(domain__isnull=True), name='unique_entry_null_domain')
+        ]
+
+    def clean(self):
+        default_policy = self.token.get_policy(domain=None)
+        if self.pk:  # update
+            # Can't change policy's default status ("domain NULLness") to maintain policy precedence
+            if (self.domain is None) != (self.pk == default_policy.pk):
+                raise ValidationError({'domain': 'Policy precedence: Cannot disable default policy when others exist.'})
+        else:  # create
+            # Can't violate policy precedence (default policy has to be first)
+            if (self.domain is not None) and (default_policy is None):
+                raise ValidationError({'domain': 'Policy precedence: The first policy must be the default policy.'})
+
+    def delete(self):
+        # Can't delete default policy when others exist
+        if (self.domain is None) and self.token.tokendomainpolicy_set.exclude(domain__isnull=True).exists():
+            raise ValidationError({'domain': "Policy precedence: Can't delete default policy when there exist others."})
+        return super().delete()
+
+    def save(self, *args, **kwargs):
+        self.clean()
+        super().save(*args, **kwargs)
+
 
 class Donation(ExportModelOperationsMixin('Donation'), models.Model):
     @staticmethod

+ 61 - 0
api/desecapi/permissions.py

@@ -21,6 +21,64 @@ class IsDomainOwner(permissions.BasePermission):
         return obj.domain.owner == request.user
 
 
+class TokenNoDomainPolicy(permissions.BasePermission):
+    """
+    Permission to check whether a token is unrestricted by any domain policy.
+    """
+
+    def has_permission(self, request, view):
+        return request.auth.get_policy(domain=None) is None
+
+
+class TokenDomainPolicyBasePermission(permissions.BasePermission):
+    """
+    Base permission to check whether a token authorizes specific actions on a domain.
+    """
+    perm_field = None
+
+    def _has_object_permission(self, request, view, obj):
+        policy = request.auth.get_policy(domain=obj)
+
+        # If the token has no domain policy, there are no restrictions
+        if policy is None:
+            return True
+
+        # Otherwise, return the requested permission
+        return getattr(policy, self.perm_field)
+
+
+class TokenHasDomainBasePermission(TokenDomainPolicyBasePermission):
+    """
+    Base permission for checking a token's domain policy, for the view domain.
+    """
+
+    def has_permission(self, request, view):
+        return self._has_object_permission(request, view, view.domain)
+
+
+class TokenHasDomainDynDNSPermission(TokenHasDomainBasePermission):
+    """
+    Custom permission to check whether a token authorizes using the dynDNS interface for the view domain.
+    """
+    perm_field = 'perm_dyndns'
+
+
+class TokenHasDomainRRsetsPermission(TokenHasDomainBasePermission):
+    """
+    Custom permission to check whether a token authorizes accessing RRsets for the view domain.
+    """
+    perm_field = 'perm_rrsets'
+
+
+class AuthTokenCorrespondsToViewToken(permissions.BasePermission):
+    """
+    Permission to check whether the view kwargs's token_id corresponds to the current token.
+    """
+
+    def has_permission(self, request, view):
+        return view.kwargs['token_id'] == str(request.auth.pk)
+
+
 class IsVPNClient(permissions.BasePermission):
     """
     Permission that requires that the user is accessing using an IP from the VPN net.
@@ -33,6 +91,9 @@ class IsVPNClient(permissions.BasePermission):
 
 
 class ManageTokensPermission(permissions.BasePermission):
+    """
+    Permission to check whether a token has "manage tokens" permission.
+    """
 
     def has_permission(self, request, view):
         return request.auth.perm_manage_tokens

+ 24 - 0
api/desecapi/serializers.py

@@ -75,6 +75,30 @@ class TokenSerializer(serializers.ModelSerializer):
         return fields
 
 
+class DomainSlugRelatedField(serializers.SlugRelatedField):
+
+    def get_queryset(self):
+        return self.context['request'].user.domains
+
+
+class TokenDomainPolicySerializer(serializers.ModelSerializer):
+    domain = DomainSlugRelatedField(allow_null=True, slug_field='name')
+
+    class Meta:
+        model = models.TokenDomainPolicy
+        fields = ('domain', 'perm_dyndns', 'perm_rrsets',)
+
+    def to_internal_value(self, data):
+        return {**super().to_internal_value(data),
+                'token': self.context['request'].user.token_set.get(id=self.context['view'].kwargs['token_id'])}
+
+    def save(self, **kwargs):
+        try:
+            return super().save(**kwargs)
+        except django.core.exceptions.ValidationError as exc:
+            raise serializers.ValidationError(exc.message_dict, code='precedence')
+
+
 class RequiredOnPartialUpdateCharField(serializers.CharField):
     """
     This field is always required, even for partial updates (e.g. using PATCH).

+ 2 - 2
api/desecapi/tests/base.py

@@ -720,8 +720,8 @@ class DesecTestCase(MockPDNSTestCase):
         return any([domain_name.endswith(f'.{suffix}') for suffix in settings.LOCAL_PUBLIC_SUFFIXES])
 
     @classmethod
-    def create_token(cls, user, name=''):
-        token = Token.objects.create(user=user, name=name)
+    def create_token(cls, user, **kwargs):
+        token = Token.objects.create(user=user, **kwargs)
         token.save()
         return token
 

+ 395 - 0
api/desecapi/tests/test_token_domain_policy.py

@@ -0,0 +1,395 @@
+from contextlib import nullcontext
+
+from django.db import transaction
+from django.db.utils import IntegrityError
+from rest_framework import status
+from rest_framework.test import APIClient
+
+from desecapi import models
+from desecapi.tests.base import DomainOwnerTestCase
+
+
+class TokenDomainPolicyClient(APIClient):
+    def _request(self, method, url, *, using, **kwargs):
+        if using is not None:
+            kwargs.update(HTTP_AUTHORIZATION=f'Token {using.plain}')
+        return method(url, **kwargs)
+
+    def _request_policy(self, method, target, *, using, domain, **kwargs):
+        domain = domain or 'default'
+        url = DomainOwnerTestCase.reverse('v1:token_domain_policies-detail', token_id=target.id, domain__name=domain)
+        return self._request(method, url, using=using, **kwargs)
+
+    def _request_policies(self, method, target, *, using, **kwargs):
+        url = DomainOwnerTestCase.reverse('v1:token_domain_policies-list', token_id=target.id)
+        return self._request(method, url, using=using, **kwargs)
+
+    def list_policies(self, target, *, using):
+        return self._request_policies(self.get, target, using=using)
+
+    def create_policy(self, target, *, using, **kwargs):
+        return self._request_policies(self.post, target, using=using, **kwargs)
+
+    def get_policy(self, target, *, using, domain):
+        return self._request_policy(self.get, target, using=using, domain=domain)
+
+    def patch_policy(self, target, *, using, domain, **kwargs):
+        return self._request_policy(self.patch, target, using=using, domain=domain, **kwargs)
+
+    def delete_policy(self, target, *, using, domain):
+        return self._request_policy(self.delete, target, using=using, domain=domain)
+
+
+class TokenDomainPolicyTestCase(DomainOwnerTestCase):
+    client_class = TokenDomainPolicyClient
+    default_data = dict(perm_dyndns=False, perm_rrsets=False)
+
+    def setUp(self):
+        super().setUp()
+        self.client.credentials()  # remove default credential (corresponding to domain owner)
+        self.token_manage = self.create_token(self.owner, perm_manage_tokens=True)
+        self.other_token = self.create_token(self.user)
+
+    def test_policy_lifecycle_without_management_permission(self):
+        # Prepare (with management token)
+        data = dict(domain=None, perm_rrsets=True)
+        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        self.assertStatus(response, status.HTTP_201_CREATED)
+        response = self.client.create_policy(self.token_manage, using=self.token_manage, data=data)
+        self.assertStatus(response, status.HTTP_201_CREATED)
+
+        # Self-inspection is fine
+        ## List
+        response = self.client.list_policies(self.token, using=self.token)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 1)
+
+        ## Get
+        response = self.client.get_policy(self.token, using=self.token, domain=None)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, self.default_data | data)
+
+        # Inspection of other tokens forbitten
+        ## List
+        response = self.client.list_policies(self.token_manage, using=self.token)
+        self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+        ## Get
+        response = self.client.get_policy(self.token_manage, using=self.token, domain=None)
+        self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # Write operations forbidden (self and other)
+        for target in [self.token, self.token_manage]:
+            # Create
+            response = self.client.create_policy(target, using=self.token)
+            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+            # Change
+            data = dict(domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True)
+            response = self.client.patch_policy(target, using=self.token, domain=self.my_domains[0].name, data=data)
+            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+            # Delete
+            response = self.client.delete_policy(target, using=self.token, domain=None)
+            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_policy_lifecycle(self):
+        # Can't do anything unauthorized
+        response = self.client.list_policies(self.token, using=None)
+        self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
+
+        response = self.client.create_policy(self.token, using=None)
+        self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
+
+        # Create
+        ## without required field
+        response = self.client.create_policy(self.token, using=self.token_manage)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data['domain'], ['This field is required.'])
+
+        ## without a default policy
+        data = dict(domain=self.my_domains[0].name)
+        with transaction.atomic():
+            response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data['domain'], ['Policy precedence: The first policy must be the default policy.'])
+
+        # List: still empty
+        response = self.client.list_policies(self.token, using=self.token_manage)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, [])
+
+        # Create
+        ## default policy
+        data = dict(domain=None, perm_rrsets=True)
+        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        self.assertStatus(response, status.HTTP_201_CREATED)
+
+        ## can't create another default policy
+        with transaction.atomic():
+            response = self.client.create_policy(self.token, using=self.token_manage, data=dict(domain=None))
+        self.assertStatus(response, status.HTTP_409_CONFLICT)
+
+        ## verify object creation
+        response = self.client.get_policy(self.token, using=self.token_manage, domain=None)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, self.default_data | data)
+
+        ## can't create policy for other user's domain
+        data = dict(domain=self.other_domain.name, perm_dyndns=True, perm_rrsets=True)
+        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data['domain'][0].code, 'does_not_exist')
+
+        ## another policy
+        data = dict(domain=self.my_domains[0].name, perm_dyndns=True)
+        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        self.assertStatus(response, status.HTTP_201_CREATED)
+
+        ## can't create policy for the same domain
+        with transaction.atomic():
+            response = self.client.create_policy(self.token, using=self.token_manage,
+                                                 data=dict(domain=self.my_domains[0].name, perm_dyndns=False))
+        self.assertStatus(response, status.HTTP_409_CONFLICT)
+
+        ## verify object creation
+        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, self.default_data | data)
+
+        # List: now has two elements
+        response = self.client.list_policies(self.token, using=self.token_manage)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 2)
+
+        # Change
+        ## all fields of a policy
+        data = dict(domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True)
+        response = self.client.patch_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name,
+                                            data=data)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, self.default_data | data)
+
+        ## verify modification
+        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[1].name)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, self.default_data | data)
+
+        ## verify that policy for former domain is gone
+        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        self.assertStatus(response, status.HTTP_404_NOT_FOUND)
+
+        ## verify that the default policy can't be changed to a non-default policy
+        with transaction.atomic():
+            response = self.client.patch_policy(self.token, using=self.token_manage, domain=None, data=data)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data,
+                         {'domain': ['Policy precedence: Cannot disable default policy when others exist.']})
+
+        ## partially modify the default policy
+        data = dict(perm_dyndns=True)
+        response = self.client.patch_policy(self.token, using=self.token_manage, domain=None, data=data)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, {'domain': None, 'perm_rrsets': True} | data)
+
+        # Delete
+        ## can't delete default policy while others exist
+        with transaction.atomic():
+            response = self.client.delete_policy(self.token, using=self.token_manage, domain=None)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data,
+                         {'domain': ["Policy precedence: Can't delete default policy when there exist others."]})
+
+        ## delete other policy
+        response = self.client.delete_policy(self.token, using=self.token_manage, domain=self.my_domains[1].name)
+        self.assertStatus(response, status.HTTP_204_NO_CONTENT)
+
+        ## delete default policy
+        response = self.client.delete_policy(self.token, using=self.token_manage, domain=None)
+        self.assertStatus(response, status.HTTP_204_NO_CONTENT)
+
+        ## idempotence: delete a non-existing policy
+        response = self.client.delete_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        self.assertStatus(response, status.HTTP_204_NO_CONTENT)
+
+        ## verify that policies are gone
+        for domain in [None, self.my_domains[0].name, self.my_domains[1].name]:
+            response = self.client.get_policy(self.token, using=self.token_manage, domain=domain)
+            self.assertStatus(response, status.HTTP_404_NOT_FOUND)
+
+        # List: empty again
+        response = self.client.list_policies(self.token, using=self.token_manage)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, [])
+
+    def test_policy_permissions(self):
+        def _reset_policies(token):
+            for policy in token.tokendomainpolicy_set.all():
+                for perm in self.default_data.keys():
+                    setattr(policy, perm, False)
+                policy.save()
+
+        def _perform_requests(name, perm, value, **kwargs):
+            responses = []
+            if value:
+                pdns_name = self._normalize_name(name).lower()
+                cm = self.assertPdnsNoRequestsBut(self.request_pdns_zone_update(name=pdns_name),
+                                                  self.request_pdns_zone_axfr(name=pdns_name))
+            else:
+                cm = nullcontext()
+
+            if perm == 'perm_dyndns':
+                data = {'username': name, 'password': self.token.plain}
+                with cm:
+                    responses.append(self.client.get(self.reverse('v1:dyndns12update'), data))
+                return responses
+
+            if perm == 'perm_rrsets':
+                url_detail = self.reverse('v1:rrset@', name=name, subname='', type='A')
+                url_list = self.reverse('v1:rrsets', name=name)
+
+                responses.append(self.client.get(url_list, **kwargs))
+                responses.append(self.client.patch(url_list, [], **kwargs))
+                responses.append(self.client.put(url_list, [], **kwargs))
+                responses.append(self.client.post(url_list, [], **kwargs))
+
+                data = {'subname': '', 'type': 'A', 'ttl': 3600, 'records': ['1.2.3.4']}
+                with cm:
+                    responses += [
+                        self.client.delete(url_detail, **kwargs),
+                        self.client.post(url_list, data=data, **kwargs),
+                        self.client.put(url_detail, data=data, **kwargs),
+                        self.client.patch(url_detail, data=data, **kwargs),
+                        self.client.get(url_detail, **kwargs),
+                    ]
+                return responses
+
+            raise ValueError(f'Unexpected permission: {perm}')
+
+        # Create
+        ## default policy
+        data = dict(domain=None)
+        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        self.assertStatus(response, status.HTTP_201_CREATED)
+
+        ## another policy
+        data = dict(domain=self.my_domains[0].name)
+        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        self.assertStatus(response, status.HTTP_201_CREATED)
+
+        ## verify object creation
+        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        self.assertStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data, self.default_data | data)
+
+        policies = {
+            self.my_domains[0]: self.token.tokendomainpolicy_set.get(domain__isnull=False),
+            self.my_domains[1]: self.token.tokendomainpolicy_set.get(domain__isnull=True),
+        }
+
+        kwargs = dict(HTTP_AUTHORIZATION=f'Token {self.token.plain}')
+
+        # For each permission type
+        for perm in self.default_data.keys():
+            # For the domain with specific policy and for the domain covered by the default policy
+            for domain in policies.keys():
+                # For both possible values of the permission
+                for value in [True, False]:
+                    # Set only that permission for that domain (on its effective policy)
+                    _reset_policies(self.token)
+                    policy = policies[domain]
+                    setattr(policy, perm, value)
+                    policy.save()
+
+                    # Perform requests that test this permission and inspect responses
+                    for response in _perform_requests(domain.name, perm, value, **kwargs):
+                        if value:
+                            self.assertIn(response.status_code, range(200, 300))
+                        else:
+                            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+                    # Can't create domain
+                    data = {'name': self.random_domain_name()}
+                    response = self.client.post(self.reverse('v1:domain-list'), data, **kwargs)
+                    self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+                    # Can't access account details
+                    response = self.client.get(self.reverse('v1:account'), **kwargs)
+                    self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_domain_owner_consistency(self):
+        models.TokenDomainPolicy(token=self.token, domain=None).save()
+
+        domain = self.my_domains[0]
+        policy = models.TokenDomainPolicy(token=self.token, domain=domain)
+        policy.save()
+
+        domain.owner = self.other_domains[0].owner
+        with self.assertRaises(IntegrityError):
+            with transaction.atomic():  # https://stackoverflow.com/a/23326971/6867099
+                domain.save()
+
+        policy.delete()
+        domain.save()
+
+    def test_token_user_consistency(self):
+        policy = models.TokenDomainPolicy(token=self.token, domain=None)
+        policy.save()
+
+        self.token.user = self.other_domains[0].owner
+        with self.assertRaises(IntegrityError):
+            with transaction.atomic():  # https://stackoverflow.com/a/23326971/6867099
+                self.token.save()
+
+        policy.delete()
+        self.token.save()
+
+    def test_domain_owner_equals_token_user(self):
+        models.TokenDomainPolicy(token=self.token, domain=None).save()
+
+        with self.assertRaises(IntegrityError):
+            with transaction.atomic():  # https://stackoverflow.com/a/23326971/6867099
+                models.TokenDomainPolicy(token=self.token, domain=self.other_domains[0]).save()
+
+        self.token.user = self.other_domain.owner
+        with self.assertRaises(IntegrityError):
+            with transaction.atomic():  # https://stackoverflow.com/a/23326971/6867099
+                self.token.save()
+
+    def test_domain_deletion(self):
+        domains = [None] + self.my_domains[:2]
+        for domain in domains:
+            models.TokenDomainPolicy(token=self.token, domain=domain).save()
+
+        domain = domains.pop()
+        domain.delete()
+        self.assertEqual(list(map(lambda x: x.domain, self.token.tokendomainpolicy_set.all())), domains)
+
+    def test_token_deletion(self):
+        domains = [None] + self.my_domains[:2]
+        policies = {}
+        for domain in domains:
+            policy = models.TokenDomainPolicy(token=self.token, domain=domain)
+            policies[domain] = policy
+            policy.save()
+
+        self.token.delete()
+        for domain, policy in policies.items():
+            self.assertFalse(models.TokenDomainPolicy.objects.filter(pk=policy.pk).exists())
+            if domain:
+                self.assertTrue(models.Domain.objects.filter(pk=domain.pk).exists())
+
+    def test_user_deletion(self):
+        domains = [None] + self.my_domains[:2]
+        for domain in domains:
+            models.TokenDomainPolicy(token=self.token, domain=domain).save()
+
+        # User can only be deleted when domains are deleted
+        for domain in self.my_domains:
+            domain.delete()
+
+        # Only the default policy should be left, so get can simply get() it
+        policy_pk = self.token.tokendomainpolicy_set.get().pk
+
+        self.token.user.delete()
+        self.assertFalse(models.TokenDomainPolicy.objects.filter(pk=policy_pk).exists())

+ 31 - 0
api/desecapi/tests/test_token_policies.py

@@ -0,0 +1,31 @@
+from rest_framework import status
+
+from desecapi.tests.base import DomainOwnerTestCase
+
+
+class TokenPoliciesTestCase(DomainOwnerTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.client.credentials()  # remove default credential (corresponding to domain owner)
+        self.token_manage = self.create_token(self.owner, perm_manage_tokens=True)
+        self.other_token = self.create_token(self.user)
+
+    def test_policies(self):
+        url = DomainOwnerTestCase.reverse('v1:token-policies-root', token_id=self.token.id)
+
+        kwargs = {}
+        response = self.client.get(url, **kwargs)
+        self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
+
+        kwargs.update(HTTP_AUTHORIZATION=f'Token {self.token_manage.plain}')
+        response = self.client.get(url, **kwargs)
+        self.assertStatus(response, status.HTTP_200_OK)
+
+        kwargs.update(HTTP_AUTHORIZATION=f'Token {self.token.plain}')
+        response = self.client.get(url, **kwargs)
+        self.assertStatus(response, status.HTTP_200_OK)
+
+        url = DomainOwnerTestCase.reverse('v1:token-policies-root', token_id=self.token_manage.id)
+        response = self.client.get(url, **kwargs)
+        self.assertStatus(response, status.HTTP_403_FORBIDDEN)

+ 5 - 0
api/desecapi/urls/version_1.py

@@ -6,6 +6,9 @@ from desecapi import views
 tokens_router = SimpleRouter()
 tokens_router.register(r'', views.TokenViewSet, basename='token')
 
+tokendomainpolicies_router = SimpleRouter()
+tokendomainpolicies_router.register(r'', views.TokenDomainPolicyViewSet, basename='token_domain_policies')
+
 auth_urls = [
     # User management
     path('', views.AccountCreateView.as_view(), name='register'),
@@ -18,6 +21,8 @@ auth_urls = [
 
     # Token management
     path('tokens/', include(tokens_router.urls)),
+    path('tokens/<token_id>/policies/', views.TokenPoliciesRoot.as_view(), name='token-policies-root'),
+    path('tokens/<token_id>/policies/domain/', include(tokendomainpolicies_router.urls)),
 ]
 
 domains_router = SimpleRouter()

+ 65 - 18
api/desecapi/views.py

@@ -3,6 +3,7 @@ import binascii
 from datetime import timedelta
 from functools import cached_property
 
+import django.core.exceptions
 from django.conf import settings
 from django.contrib.auth import user_logged_in
 from django.contrib.auth.hashers import is_password_usable
@@ -22,11 +23,10 @@ from rest_framework.settings import api_settings
 from rest_framework.views import APIView
 
 import desecapi.authentication as auth
-from desecapi import metrics, models, serializers
+from desecapi import metrics, models, permissions, serializers
 from desecapi.exceptions import ConcurrencyException
 from desecapi.pdns import get_serials
 from desecapi.pdns_change_tracker import PDNSChangeTracker
-from desecapi.permissions import ManageTokensPermission, IsDomainOwner, IsOwner, IsVPNClient, WithinDomainLimit
 from desecapi.renderers import PlainTextRenderer
 
 
@@ -69,7 +69,7 @@ class IdempotentDestroyMixin:
 
 class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
     serializer_class = serializers.TokenSerializer
-    permission_classes = (IsAuthenticated, ManageTokensPermission,)
+    permission_classes = (IsAuthenticated, permissions.ManageTokensPermission,)
     throttle_scope = 'account_management_passive'
 
     def get_queryset(self):
@@ -97,10 +97,12 @@ class DomainViewSet(IdempotentDestroyMixin,
 
     @property
     def permission_classes(self):
-        permissions = [IsAuthenticated, IsOwner]
+        ret = [IsAuthenticated, permissions.IsOwner]
         if self.action == 'create':
-            permissions.append(WithinDomainLimit)
-        return permissions
+            ret.append(permissions.WithinDomainLimit)
+        if self.request.method not in SAFE_METHODS:
+            ret.append(permissions.TokenNoDomainPolicy)
+        return ret
 
     @property
     def throttle_scope(self):
@@ -150,8 +152,54 @@ class DomainViewSet(IdempotentDestroyMixin,
                 parent_domain.update_delegation(instance)
 
 
+class TokenPoliciesRoot(APIView):
+    permission_classes = [
+        IsAuthenticated,
+        permissions.HasManageTokensPermission | permissions.AuthTokenCorrespondsToViewToken,
+    ]
+
+    def get(self, request, *args, **kwargs):
+        return Response({'domain': reverse('token_domain_policies-list', request=request, kwargs=kwargs)})
+
+
+class TokenDomainPolicyViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
+    lookup_field = 'domain__name'
+    lookup_value_regex = DomainViewSet.lookup_value_regex
+    pagination_class = None
+    serializer_class = serializers.TokenDomainPolicySerializer
+    throttle_scope = 'account_management_passive'
+
+    @property
+    def permission_classes(self):
+        ret = [IsAuthenticated]
+        if self.request.method in SAFE_METHODS:
+            ret.append(permissions.ManageTokensPermission | permissions.AuthTokenCorrespondsToViewToken)
+        else:
+            ret.append(permissions.ManageTokensPermission)
+        return ret
+
+    def dispatch(self, request, *args, **kwargs):
+        # map default policy onto domain_id IS NULL
+        lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
+        try:
+            if kwargs[lookup_url_kwarg] == 'default':
+                kwargs[lookup_url_kwarg] = None
+        except KeyError:
+            pass
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_queryset(self):
+        return models.TokenDomainPolicy.objects.filter(token_id=self.kwargs['token_id'], token__user=self.request.user)
+
+    def perform_destroy(self, instance):
+        try:
+            super().perform_destroy(instance)
+        except django.core.exceptions.ValidationError as exc:
+            raise ValidationError(exc.message_dict, code='precedence')
+
+
 class SerialListView(APIView):
-    permission_classes = (IsVPNClient,)
+    permission_classes = (permissions.IsVPNClient,)
     throttle_classes = []  # don't break slaves when they ask too often (our cached responses are cheap)
 
     def get(self, request, *args, **kwargs):
@@ -165,7 +213,14 @@ class SerialListView(APIView):
 
 class RRsetView:
     serializer_class = serializers.RRsetSerializer
-    permission_classes = (IsAuthenticated, IsDomainOwner,)
+    permission_classes = (IsAuthenticated, permissions.IsDomainOwner, permissions.TokenHasDomainRRsetsPermission,)
+
+    @property
+    def domain(self):
+        try:
+            return self.request.user.domains.get(name=self.kwargs['name'])
+        except models.Domain.DoesNotExist:
+            raise Http404
 
     @property
     def throttle_scope(self):
@@ -183,15 +238,6 @@ class RRsetView:
         # noinspection PyUnresolvedReferences
         return {**super().get_serializer_context(), 'domain': self.domain}
 
-    def initial(self, request, *args, **kwargs):
-        # noinspection PyUnresolvedReferences
-        super().initial(request, *args, **kwargs)
-        try:
-            # noinspection PyAttributeOutsideInit, PyUnresolvedReferences
-            self.domain = self.request.user.domains.get(name=self.kwargs['name'])
-        except models.Domain.DoesNotExist:
-            raise Http404
-
     def perform_update(self, serializer):
         with PDNSChangeTracker():
             super().perform_update(serializer)
@@ -287,6 +333,7 @@ class Root(APIView):
 
 class DynDNS12UpdateView(generics.GenericAPIView):
     authentication_classes = (auth.TokenAuthentication, auth.BasicTokenAuthentication, auth.URLParamAuthentication,)
+    permission_classes = (permissions.TokenHasDomainDynDNSPermission,)
     renderer_classes = [PlainTextRenderer]
     serializer_class = serializers.RRsetSerializer
     throttle_scope = 'dyndns'
@@ -492,7 +539,7 @@ class AccountCreateView(generics.CreateAPIView):
 
 
 class AccountView(generics.RetrieveAPIView):
-    permission_classes = (IsAuthenticated,)
+    permission_classes = (IsAuthenticated, permissions.TokenNoDomainPolicy,)
     serializer_class = serializers.UserSerializer
     throttle_scope = 'account_management_passive'
 

+ 1 - 0
api/requirements.txt

@@ -7,6 +7,7 @@ django-cors-headers~=3.10.1
 djangorestframework~=3.13.1
 django-celery-email~=3.0.0
 django-netfields~=1.2.4
+django-pgtrigger~=2.4.0
 django-prometheus~=2.2.0
 dnspython~=2.1.0
 httpretty~=1.0.5

+ 2 - 0
docs/auth/account.rst

@@ -166,6 +166,8 @@ header to the token value, prefixed with ``Token``::
         --header "Authorization: Token i-T3b1h_OI-H9ab8tRS98stGtURe"
 
 
+.. _retrieve-account-information:
+
 Retrieve Account Information
 ````````````````````````````
 

+ 121 - 0
docs/auth/tokens.rst

@@ -268,6 +268,127 @@ If you do not have the token UUID, but you do have the token value itself, you
 can use the :ref:`log-out` endpoint to delete it.
 
 
+Token Scoping: Domain Policies
+``````````````````````````````
+Tokens by default can be used to authorize arbitrary actions within the user's
+account, including some administrative tasks and DNS operations on any domain.
+As such, tokens are considered *privileged* when no further configuration is
+done.
+(This applies to v1 of the API and may change in a later version.)
+
+Tokens can be *restricted* using Token Policies, which narrow down the scope
+of influence for a given API token.
+Using policies, the token's power can be limited in two ways:
+
+1. the types of DNS operations that can be performed, such as :ref:`dynDNS
+   updates <update-api>` or :ref:`general RRset management <manage-rrsets>`.
+
+2. the set of domains on which these actions can be performed.
+
+Policies can be configured on a per-domain basis.
+Domains for which no explicit policy is configured are subject to the token's
+default policy.
+It is required to create such a default policy before any domain-specific
+policies can be created.
+
+Tokens with at least one policy are considered *restricted*, with their scope
+explicitly limited to DNS record management.
+They can perform neither :ref:`retrieve-account-information` nor
+:ref:`domain-management` (such as domain creation or deletion).
+
+**Please note:**  Token policies are *independent* of high-level token
+permissions that can be assigned when `Creating a Token`_.
+In particular, a restricted token that at the same time has the
+``perm_manage_tokens`` permission is able to free itself from its
+restrictions (see `Token Field Reference`_).
+
+
+Token Domain Policy Field Reference
+-----------------------------------
+
+A JSON object representing a token domain policy has the following structure::
+
+    {
+        "domain": "example.com",
+        "perm_dyndns": false,
+        "perm_rrsets": true
+    }
+
+Field details:
+
+``domain``
+    :Access mode: read, write
+    :Type: string or ``null``
+
+    Domain name to which the policy applies.  ``null`` for the default policy.
+
+``perm_dyndns``
+    :Access mode: read, write
+    :Type: boolean
+
+    Indicates whether :ref:`dynDNS updates <update-api>` are allowed.
+    Defaults to ``false``.
+
+``perm_rrsets``
+    :Access mode: read, write
+    :Type: boolean
+
+    Indicates whether :ref:`general RRset management <manage-rrsets>` is
+    allowed.  Defaults to ``false``.
+
+
+Token Domain Policy Management
+------------------------------
+Token Domain Policies are managed using the ``policies/domain/`` endpoint
+under the token's URL.
+Usage of this endpoint requires that the request's authorization token has the
+``perm_manage_tokens`` flag.
+
+Semantics, input validation, and error handling follow the same style as the
+rest of the API, so is not documented in detail here.
+For example, to retrieve a list of policies for a given token, issue a ``GET``
+request as follows::
+
+    curl -X GET https://desec.io/api/v1/auth/tokens/{id}/policies/domains/ \
+        --header "Authorization: Token mu4W4MHuSc0Hy-GD1h_dnKuZBond"
+
+The server will respond with a list of token domain policy objects.
+
+To create the default policy, send a request like::
+
+    curl -X POST https://desec.io/api/v1/auth/tokens/{id}/policies/domain/ \
+        --header "Authorization: Token mu4W4MHuSc0Hy-GD1h_dnKuZBond" \
+        --header "Content-Type: application/json" --data @- <<< \
+        '{"domain": null}'
+
+This will create a default policy.  Permission flags that are not given are
+assumed to be ``false``.  To enable permissions, they have to be set to
+``true`` explicitly.  As an example, let's create a policy that only allows
+dynDNS updates for a specific domain::
+
+    curl -X POST https://desec.io/api/v1/auth/tokens/{id}/policies/domain/ \
+        --header "Authorization: Token mu4W4MHuSc0Hy-GD1h_dnKuZBond" \
+        --header "Content-Type: application/json" --data @- <<< \
+        '{"domain": "example.dedyn.io", "perm_dyndns": true}'
+
+You can retrieve (``GET``), update (``PATCH``, ``PUT``), and remove
+(``DELETE``) policies by appending their ``domain`` to the endpoint::
+
+    curl -X DELETE https://desec.io/api/v1/auth/tokens/{id}/policies/domains/{domain}/ \
+        --header "Authorization: Token mu4W4MHuSc0Hy-GD1h_dnKuZBond"
+
+The default policy can be accessed using the special domain name ``default``
+(``/api/v1/auth/tokens/{id}/policies/domains/default/``).
+
+When modifying or deleting policies, the API enforces the default policy's
+primacy:
+You cannot create domain-specific policies without first creating a default
+policy, and you cannot remove a default policy when other policies are still
+in place.
+
+During deletion of tokens, users, or domains, policies are cleaned up
+automatically.  (It is not necessary to first remove policies manually.)
+
 Security Considerations
 ```````````````````````
 

+ 2 - 0
docs/dyndns/update-api.rst

@@ -1,3 +1,5 @@
+.. _update-api:
+
 IP Update API
 ~~~~~~~~~~~~~
 

+ 48 - 35
docs/endpoint-reference.rst

@@ -6,41 +6,54 @@ Endpoint Reference
 The following table summarizes basic information about the deSEC API endpoints used
 for :ref:`managing users <manage-account>` and :ref:`tokens <manage-tokens>`.
 
-+------------------------------------------------+------------+---------------------------------------------+
-| Endpoint ``/api/v1``...                        | Methods    | Use case                                    |
-+================================================+============+=============================================+
-| ...\ ``/auth/``                                | ``POST``   | Register user account                       |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/account/``                        | ``GET``    | Retrieve user account information           |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/account/change-email/``           | ``POST``   | Request account email address change        |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/account/reset-password/``         | ``POST``   | Request password reset                      |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/account/delete/``                 | ``POST``   | Request account deletion                    |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/login/``                          | ``POST``   | Log in and request authentication token     |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/logout/``                         | ``POST``   | Log out (= delete current token)            |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/tokens/``                         | ``GET``    | Retrieve all current tokens                 |
-|                                                +------------+---------------------------------------------+
-|                                                | ``POST``   | Create new token                            |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/tokens/{id}/``                    | ``GET``    | Retrieve token                              |
-|                                                +------------+---------------------------------------------+
-|                                                | ``DELETE`` | Delete token                                |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/captcha/``                             | ``POST``   | Obtain captcha                              |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/v/activate-account/{code}/``           | ``POST``   | Confirm email address for new account       |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/v/reset-password/{code}/``             | ``POST``   | Confirm password reset                      |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/v/change-email/{code}/``               | ``POST``   | Confirm email address change                |
-+------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/v/delete-account/{code}/``             | ``POST``   | Confirm account deletion                    |
-+------------------------------------------------+------------+---------------------------------------------+
++------------------------------------------------------+------------+---------------------------------------------+
+| Endpoint ``/api/v1``...                              | Methods    | Use case                                    |
++======================================================+============+=============================================+
+| ...\ ``/auth/``                                      | ``POST``   | Register user account                       |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/account/``                              | ``GET``    | Retrieve user account information           |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/account/change-email/``                 | ``POST``   | Request account email address change        |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/account/reset-password/``               | ``POST``   | Request password reset                      |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/account/delete/``                       | ``POST``   | Request account deletion                    |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/login/``                                | ``POST``   | Log in and request authentication token     |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/logout/``                               | ``POST``   | Log out (= delete current token)            |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/tokens/``                               | ``GET``    | Retrieve all current tokens                 |
+|                                                      +------------+---------------------------------------------+
+|                                                      | ``POST``   | Create new token                            |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/tokens/{id}/``                          | ``GET``    | Retrieve token                              |
+|                                                      +------------+---------------------------------------------+
+|                                                      | ``DELETE`` | Delete token                                |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/tokens/{id}/policies/domain/``          | ``GET``    | Retrieve all domain policies for the given  |
+|                                                      |            | token                                       |
+|                                                      +------------+---------------------------------------------+
+|                                                      | ``POST``   | Create a domain policy for the given token  |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/tokens/{id}/policies/domain/{domain}/`` | ``GET``    | Retrieve a specific token domain policy     |
+|                                                      +------------+---------------------------------------------+
+|                                                      | ``PATCH``  | Modify a token domain policy                |
+|                                                      +------------+---------------------------------------------+
+|                                                      | ``PUT``    | Replace a token domain policy               |
+|                                                      +------------+---------------------------------------------+
+|                                                      | ``DELETE`` | Delete a token domain policy                |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/captcha/``                                   | ``POST``   | Obtain captcha                              |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/v/activate-account/{code}/``                 | ``POST``   | Confirm email address for new account       |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/v/reset-password/{code}/``                   | ``POST``   | Confirm password reset                      |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/v/change-email/{code}/``                     | ``POST``   | Confirm email address change                |
++------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/v/delete-account/{code}/``                   | ``POST``   | Confirm account deletion                    |
++------------------------------------------------------+------------+---------------------------------------------+
 
 The following table summarizes basic information about the deSEC API endpoints used
 for :ref:`domain-management` and :ref:`Retrieving and Manipulating DNS