Browse Source

feat(api): add token domain policy

Peter Thomassen 4 years ago
parent
commit
5233cceb56

+ 1 - 0
api/api/settings.py

@@ -47,6 +47,7 @@ INSTALLED_APPS = (
     'desecapi.apps.AppConfig',
     'desecapi.apps.AppConfig',
     'corsheaders',
     'corsheaders',
     'django_prometheus',
     'django_prometheus',
+    'pgtrigger',
 )
 )
 
 
 MIDDLEWARE = (
 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
 from hashlib import sha256
 
 
 import dns
 import dns
+import pgtrigger
 import psl_dns
 import psl_dns
 import rest_framework.authtoken.models
 import rest_framework.authtoken.models
 from django.conf import settings
 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.exceptions import ValidationError
 from django.core.mail import EmailMessage, get_connection
 from django.core.mail import EmailMessage, get_connection
 from django.core.validators import MinValueValidator, RegexValidator
 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 import CharField, F, Manager, Q, Value
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.db.models.functions import Concat, Length
 from django.db.models.functions import Concat, Length
@@ -244,6 +245,10 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
     _keys = None
     _keys = None
     objects = DomainManager()
     objects = DomainManager()
 
 
+    class Meta:
+        constraints = [models.UniqueConstraint(fields=['id', 'owner'], name='unique_id_owner')]
+        ordering = ('created',)
+
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         if isinstance(kwargs.get('owner'), AnonymousUser):
         if isinstance(kwargs.get('owner'), AnonymousUser):
             kwargs = {**kwargs, 'owner': None}  # make a copy and override
             kwargs = {**kwargs, 'owner': None}  # make a copy and override
@@ -403,9 +408,6 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
-    class Meta:
-        ordering = ('created',)
-
 
 
 class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models.Token):
 class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models.Token):
     @staticmethod
     @staticmethod
@@ -421,10 +423,14 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
     allowed_subnets = ArrayField(CidrAddressField(), default=_allowed_subnets_default.__func__)
     allowed_subnets = ArrayField(CidrAddressField(), default=_allowed_subnets_default.__func__)
     max_age = models.DurationField(null=True, default=None, validators=[MinValueValidator(timedelta(0))])
     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))])
     max_unused_period = models.DurationField(null=True, default=None, validators=[MinValueValidator(timedelta(0))])
+    domain_policies = models.ManyToManyField(Domain, through='TokenDomainPolicy')
 
 
     plain = None
     plain = None
     objects = NetManager()
     objects = NetManager()
 
 
+    class Meta:
+        constraints = [models.UniqueConstraint(fields=['id', 'user'], name='unique_id_user')]
+
     @property
     @property
     def is_valid(self):
     def is_valid(self):
         now = timezone.now()
         now = timezone.now()
@@ -454,6 +460,91 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
     def make_hash(plain):
     def make_hash(plain):
         return make_password(plain, salt='static', hasher='pbkdf2_sha256_iter1')
         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):
 class Donation(ExportModelOperationsMixin('Donation'), models.Model):
     @staticmethod
     @staticmethod

+ 61 - 0
api/desecapi/permissions.py

@@ -21,6 +21,64 @@ class IsDomainOwner(permissions.BasePermission):
         return obj.domain.owner == request.user
         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):
 class IsVPNClient(permissions.BasePermission):
     """
     """
     Permission that requires that the user is accessing using an IP from the VPN net.
     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):
 class ManageTokensPermission(permissions.BasePermission):
+    """
+    Permission to check whether a token has "manage tokens" permission.
+    """
 
 
     def has_permission(self, request, view):
     def has_permission(self, request, view):
         return request.auth.perm_manage_tokens
         return request.auth.perm_manage_tokens

+ 24 - 0
api/desecapi/serializers.py

@@ -75,6 +75,30 @@ class TokenSerializer(serializers.ModelSerializer):
         return fields
         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):
 class RequiredOnPartialUpdateCharField(serializers.CharField):
     """
     """
     This field is always required, even for partial updates (e.g. using PATCH).
     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])
         return any([domain_name.endswith(f'.{suffix}') for suffix in settings.LOCAL_PUBLIC_SUFFIXES])
 
 
     @classmethod
     @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()
         token.save()
         return token
         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 = SimpleRouter()
 tokens_router.register(r'', views.TokenViewSet, basename='token')
 tokens_router.register(r'', views.TokenViewSet, basename='token')
 
 
+tokendomainpolicies_router = SimpleRouter()
+tokendomainpolicies_router.register(r'', views.TokenDomainPolicyViewSet, basename='token_domain_policies')
+
 auth_urls = [
 auth_urls = [
     # User management
     # User management
     path('', views.AccountCreateView.as_view(), name='register'),
     path('', views.AccountCreateView.as_view(), name='register'),
@@ -18,6 +21,8 @@ auth_urls = [
 
 
     # Token management
     # Token management
     path('tokens/', include(tokens_router.urls)),
     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()
 domains_router = SimpleRouter()

+ 65 - 18
api/desecapi/views.py

@@ -3,6 +3,7 @@ import binascii
 from datetime import timedelta
 from datetime import timedelta
 from functools import cached_property
 from functools import cached_property
 
 
+import django.core.exceptions
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import user_logged_in
 from django.contrib.auth import user_logged_in
 from django.contrib.auth.hashers import is_password_usable
 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
 from rest_framework.views import APIView
 
 
 import desecapi.authentication as auth
 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.exceptions import ConcurrencyException
 from desecapi.pdns import get_serials
 from desecapi.pdns import get_serials
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.pdns_change_tracker import PDNSChangeTracker
-from desecapi.permissions import ManageTokensPermission, IsDomainOwner, IsOwner, IsVPNClient, WithinDomainLimit
 from desecapi.renderers import PlainTextRenderer
 from desecapi.renderers import PlainTextRenderer
 
 
 
 
@@ -69,7 +69,7 @@ class IdempotentDestroyMixin:
 
 
 class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
 class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
     serializer_class = serializers.TokenSerializer
     serializer_class = serializers.TokenSerializer
-    permission_classes = (IsAuthenticated, ManageTokensPermission,)
+    permission_classes = (IsAuthenticated, permissions.ManageTokensPermission,)
     throttle_scope = 'account_management_passive'
     throttle_scope = 'account_management_passive'
 
 
     def get_queryset(self):
     def get_queryset(self):
@@ -97,10 +97,12 @@ class DomainViewSet(IdempotentDestroyMixin,
 
 
     @property
     @property
     def permission_classes(self):
     def permission_classes(self):
-        permissions = [IsAuthenticated, IsOwner]
+        ret = [IsAuthenticated, permissions.IsOwner]
         if self.action == 'create':
         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
     @property
     def throttle_scope(self):
     def throttle_scope(self):
@@ -150,8 +152,54 @@ class DomainViewSet(IdempotentDestroyMixin,
                 parent_domain.update_delegation(instance)
                 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):
 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)
     throttle_classes = []  # don't break slaves when they ask too often (our cached responses are cheap)
 
 
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
@@ -165,7 +213,14 @@ class SerialListView(APIView):
 
 
 class RRsetView:
 class RRsetView:
     serializer_class = serializers.RRsetSerializer
     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
     @property
     def throttle_scope(self):
     def throttle_scope(self):
@@ -183,15 +238,6 @@ class RRsetView:
         # noinspection PyUnresolvedReferences
         # noinspection PyUnresolvedReferences
         return {**super().get_serializer_context(), 'domain': self.domain}
         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):
     def perform_update(self, serializer):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
             super().perform_update(serializer)
             super().perform_update(serializer)
@@ -287,6 +333,7 @@ class Root(APIView):
 
 
 class DynDNS12UpdateView(generics.GenericAPIView):
 class DynDNS12UpdateView(generics.GenericAPIView):
     authentication_classes = (auth.TokenAuthentication, auth.BasicTokenAuthentication, auth.URLParamAuthentication,)
     authentication_classes = (auth.TokenAuthentication, auth.BasicTokenAuthentication, auth.URLParamAuthentication,)
+    permission_classes = (permissions.TokenHasDomainDynDNSPermission,)
     renderer_classes = [PlainTextRenderer]
     renderer_classes = [PlainTextRenderer]
     serializer_class = serializers.RRsetSerializer
     serializer_class = serializers.RRsetSerializer
     throttle_scope = 'dyndns'
     throttle_scope = 'dyndns'
@@ -492,7 +539,7 @@ class AccountCreateView(generics.CreateAPIView):
 
 
 
 
 class AccountView(generics.RetrieveAPIView):
 class AccountView(generics.RetrieveAPIView):
-    permission_classes = (IsAuthenticated,)
+    permission_classes = (IsAuthenticated, permissions.TokenNoDomainPolicy,)
     serializer_class = serializers.UserSerializer
     serializer_class = serializers.UserSerializer
     throttle_scope = 'account_management_passive'
     throttle_scope = 'account_management_passive'
 
 

+ 1 - 0
api/requirements.txt

@@ -7,6 +7,7 @@ django-cors-headers~=3.10.1
 djangorestframework~=3.13.1
 djangorestframework~=3.13.1
 django-celery-email~=3.0.0
 django-celery-email~=3.0.0
 django-netfields~=1.2.4
 django-netfields~=1.2.4
+django-pgtrigger~=2.4.0
 django-prometheus~=2.2.0
 django-prometheus~=2.2.0
 dnspython~=2.1.0
 dnspython~=2.1.0
 httpretty~=1.0.5
 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"
         --header "Authorization: Token i-T3b1h_OI-H9ab8tRS98stGtURe"
 
 
 
 
+.. _retrieve-account-information:
+
 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.
 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
 Security Considerations
 ```````````````````````
 ```````````````````````
 
 

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

@@ -1,3 +1,5 @@
+.. _update-api:
+
 IP 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
 The following table summarizes basic information about the deSEC API endpoints used
 for :ref:`managing users <manage-account>` and :ref:`tokens <manage-tokens>`.
 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
 The following table summarizes basic information about the deSEC API endpoints used
 for :ref:`domain-management` and :ref:`Retrieving and Manipulating DNS
 for :ref:`domain-management` and :ref:`Retrieving and Manipulating DNS