Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto-scoped tokens #990

Merged
merged 10 commits into from
Dec 1, 2024
Merged
2 changes: 1 addition & 1 deletion api/desecapi/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def authenticate_credentials(self, key):
if not token.is_valid:
raise exceptions.AuthenticationFailed("Invalid token.")
token.last_used = timezone.now()
token.save()
token.save(update_fields=["last_used"])
return user, token


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 5.1.2 on 2024-11-01 14:29

import django.db.migrations.operations.special
from django.db import migrations, models


def forwards_func(apps, schema_editor):
Token = apps.get_model("desecapi", "Token")
db_alias = schema_editor.connection.alias
Token.objects.using(db_alias).filter(tokendomainpolicy__isnull=True).update(
perm_create_domain=True, perm_delete_domain=True
)


class Migration(migrations.Migration):

dependencies = [
("desecapi", "0038_user_throttle_daily_rate"),
]

operations = [
migrations.AddField(
model_name="token",
name="perm_create_domain",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="token",
name="perm_delete_domain",
field=models.BooleanField(default=False),
),
migrations.RunPython(
code=forwards_func,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Generated by Django 5.1.3 on 2024-11-21 15:45

import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("desecapi", "0039_token_perm_create_domain_token_perm_delete_domain"),
]

operations = [
migrations.AddField(
model_name="token",
name="auto_policy",
field=models.BooleanField(default=False),
),
pgtrigger.migrations.AddTrigger(
model_name="token",
trigger=pgtrigger.compiler.Trigger(
name="token_auto_policy",
sql=pgtrigger.compiler.UpsertTriggerSql(
constraint="CONSTRAINT",
func="\n IF\n NEW.auto_policy = true AND NOT EXISTS(\n SELECT * FROM desecapi_tokendomainpolicy WHERE token_id = NEW.id AND domain_id IS NULL AND subname IS NULL AND type IS NULL\n )\n THEN\n RAISE EXCEPTION 'Token auto policy without a default policy is not allowed. (token.id=%s)', NEW.id;\n END IF;\n RETURN NULL;\n ",
hash="f2cc6e70892817e04cbfbbff63bd4ddbe05b9aa5",
operation="UPDATE OR INSERT",
pgid="pgtrigger_token_auto_policy_8e6d9",
table="desecapi_token",
timing="DEFERRABLE INITIALLY DEFERRED",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="tokendomainpolicy",
trigger=pgtrigger.compiler.Trigger(
name="default_policy_when_auto_policy",
sql=pgtrigger.compiler.UpsertTriggerSql(
func="\n IF\n OLD.domain_id IS NULL AND OLD.subname IS NULL AND OLD.type IS NULL AND (SELECT auto_policy FROM desecapi_token WHERE id = OLD.token_id) = true\n THEN\n RAISE EXCEPTION 'Cannot delete default policy while auto_policy is in effect. (tokendomainpolicy.id=%s)', OLD.id;\n END IF;\n RETURN OLD;\n ",
hash="3d55e73e6ae7d089b5ff136ac16f0c2675285bf2",
operation="DELETE",
pgid="pgtrigger_default_policy_when_auto_policy_a1fd2",
table="desecapi_tokendomainpolicy",
when="BEFORE",
),
),
),
]
4 changes: 2 additions & 2 deletions api/desecapi/models/mfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ class Meta:
models.UniqueConstraint(fields=["user", "name"], name="unique_user_name"),
]

@transaction.atomic()
@transaction.atomic
def delete(self):
if self.last_used is not None:
self.user.save(credentials_changed=True)
return super().delete()

@transaction.atomic()
@transaction.atomic
def save(self, *args, **kwargs):
if not self.user.mfa_enabled: # enabling MFA
self.user.save(credentials_changed=True)
Expand Down
105 changes: 104 additions & 1 deletion api/desecapi/models/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django.contrib.postgres.fields import ArrayField
from django.core import validators
from django.core.exceptions import ValidationError
from django.db import models
from django.db import models, transaction
from django.db.models import F, Q
from django.utils import timezone
from django_prometheus.models import ExportModelOperationsMixin
Expand Down Expand Up @@ -41,6 +41,8 @@ def _allowed_subnets_default():
name = models.CharField("Name", blank=True, max_length=64)
last_used = models.DateTimeField(null=True, blank=True)
mfa = models.BooleanField(default=None, null=True)
perm_create_domain = models.BooleanField(default=False)
perm_delete_domain = models.BooleanField(default=False)
perm_manage_tokens = models.BooleanField(default=False)
allowed_subnets = ArrayField(
CidrAddressField(), default=_allowed_subnets_default.__func__
Expand All @@ -50,6 +52,7 @@ def _allowed_subnets_default():
null=True, default=None, validators=_validators
)
domain_policies = models.ManyToManyField("Domain", through="TokenDomainPolicy")
auto_policy = models.BooleanField(default=False)

plain = None
objects = NetManager()
Expand All @@ -58,6 +61,27 @@ class Meta:
constraints = [
models.UniqueConstraint(fields=["id", "user"], name="unique_id_user")
]
triggers = [
# Ensure that a default policy is defined when auto_policy=true
pgtrigger.Trigger(
name="token_auto_policy",
operation=pgtrigger.Update | pgtrigger.Insert,
when=pgtrigger.After,
timing=pgtrigger.Deferred,
func=pgtrigger.Func(
"""
IF
NEW.auto_policy = true AND NOT EXISTS(
SELECT * FROM {meta.many_to_many[0].remote_field.through._meta.db_table} WHERE token_id = NEW.id AND domain_id IS NULL AND subname IS NULL AND type IS NULL
)
THEN
RAISE EXCEPTION 'Token auto policy without a default policy is not allowed. (token.id=%s)', NEW.id;
END IF;
RETURN NULL;
"""
),
),
]

@property
def is_valid(self):
Expand Down Expand Up @@ -106,6 +130,51 @@ def get_policy(self, rrset=None):
.first()
)

def can_safely_delete_domain(self, domain):
forbidden = (
# Check if token is explicitly prohibited from writing some RRsets in this domain
# (priority order 1-4, see /docs/auth/tokens.rst#token-scoping-policies)
self.tokendomainpolicy_set.filter(domain=domain)
.filter(perm_write=False)
.exists()
or
# Check that the token has no permissive default policy for this domain
# (priority order 4) and apply fall-through to domain-independent policies (5-8)
(
not self.tokendomainpolicy_set.filter(
domain=domain, subname=None, type=None
)
.filter(perm_write=True)
.exists()
# Fall-through. Uses a conservative approximation and does not account for
# permissive policies of priority order 1, 2, 3 shadowing restrictive policies
# of priority order 5, 6, 7, respectively. For details, see
# https://github.com/desec-io/desec-stack/pull/990#discussion_r1864977009.
and self.tokendomainpolicy_set.filter(domain=None)
.filter(perm_write=False)
.exists()
)
)
return not forbidden
peterthomassen marked this conversation as resolved.
Show resolved Hide resolved

def clean(self):
if not self.auto_policy:
return
default_policy = self.get_policy()
if default_policy and default_policy.perm_write:
raise ValidationError(
{"auto_policy": ["Auto policy requires a restrictive default policy."]}
)

@transaction.atomic
def save(self, *args, **kwargs):
# Do not perform policy checks when only updating fields like last_used
if "auto_policy" in kwargs.get("update_fields", ["auto_policy"]):
self.clean()
super().save(*args, **kwargs)
if self.auto_policy and self.get_policy() is None:
TokenDomainPolicy(token=self).save()


class TokenDomainPolicy(ExportModelOperationsMixin("TokenDomainPolicy"), models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
Expand Down Expand Up @@ -161,6 +230,22 @@ class Meta:
"""
),
),
# Ensure default policy when auto_policy is in effect
pgtrigger.Trigger(
name="default_policy_when_auto_policy",
operation=pgtrigger.Delete,
when=pgtrigger.Before,
func=pgtrigger.Func(
"""
IF
OLD.domain_id IS NULL AND OLD.subname IS NULL AND OLD.type IS NULL AND (SELECT auto_policy FROM {fields.token.remote_field.model._meta.db_table} WHERE id = OLD.token_id) = true
THEN
RAISE EXCEPTION 'Cannot delete default policy while auto_policy is in effect. (tokendomainpolicy.id=%s)', OLD.id;
END IF;
RETURN OLD;
"""
),
),
]

@property
Expand Down Expand Up @@ -194,6 +279,15 @@ def clean(self):
]
}
)
# Can't relax default policy if auto_policy is in effect
if self.perm_write and self.is_default_policy and self.token.auto_policy:
raise ValidationError(
{
"perm_write": [
"Must be false when auto_policy is in effect for the token."
]
}
)

def delete(self, *args, **kwargs):
# Can't delete default policy when others exist
Expand All @@ -208,6 +302,15 @@ def delete(self, *args, **kwargs):
]
}
)
# Can't delete default policy when auto_policy is in effect
if self.is_default_policy and self.token.auto_policy:
raise ValidationError(
{
"non_field_errors": [
"Can't delete default policy when auto_policy is in effect for the token."
]
}
)
return super().delete(*args, **kwargs)

def save(self, *args, **kwargs):
Expand Down
30 changes: 21 additions & 9 deletions api/desecapi/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,6 @@ def has_permission(self, request, view):
return request.user == view.domain.owner


class TokenNoDomainPolicy(permissions.BasePermission):
"""
Permission to check whether a token is unrestricted by any policy.
"""

def has_permission(self, request, view):
return not request.auth.tokendomainpolicy_set.exists()


class TokenHasRRsetPermission(permissions.BasePermission):
"""
Permission to check whether a token authorizes writing the view's RRset.
Expand Down Expand Up @@ -121,6 +112,27 @@ def has_permission(self, request, view):
return ip in IPv4Network("10.8.0.0/24")


class HasCreateDomainPermission(permissions.BasePermission):
"""
Permission to check whether a token has "create domain" permission.
"""

def has_permission(self, request, view):
return request.auth.perm_create_domain


class HasDeleteDomainPermission(permissions.BasePermission):
"""
Permission to check whether a token has "delete domain" permission.
"""

def has_permission(self, request, view):
return request.auth.perm_delete_domain

def has_object_permission(self, request, view, obj):
return request.auth.can_safely_delete_domain(obj)


class HasManageTokensPermission(permissions.BasePermission):
"""
Permission to check whether a token has "manage tokens" permission.
Expand Down
9 changes: 9 additions & 0 deletions api/desecapi/serializers/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ class Meta:
"max_age",
"max_unused_period",
"name",
"perm_create_domain",
"perm_delete_domain",
"perm_manage_tokens",
"allowed_subnets",
"auto_policy",
"is_valid",
"token",
)
Expand All @@ -36,6 +39,12 @@ def get_fields(self):
fields.pop("token")
return fields

def save(self, **kwargs):
try:
return super().save(**kwargs)
except django.core.exceptions.ValidationError as exc:
raise serializers.ValidationError(exc.message_dict)


class DomainSlugRelatedField(serializers.SlugRelatedField):
def get_queryset(self):
Expand Down
8 changes: 4 additions & 4 deletions api/desecapi/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1002,9 +1002,7 @@ def has_local_suffix(cls, domain_name: str):

@classmethod
def create_token(cls, user, **kwargs):
token = Token.objects.create(user=user, **kwargs)
token.save()
return token
return Token.objects.create(user=user, **kwargs)

@classmethod
def create_user(cls, needs_captcha=False, **kwargs):
Expand Down Expand Up @@ -1352,7 +1350,9 @@ def setUpTestDataWithPdns(cls):
cls.create_rr_set(cls.my_domain, ["127.0.0.1", "3.2.2.3"], type="A", ttl=123)
cls.create_rr_set(cls.other_domain, ["40.1.1.1"], type="A", ttl=456)

cls.token = cls.create_token(user=cls.owner)
cls.token = cls.create_token(
user=cls.owner, perm_create_domain=True, perm_delete_domain=True
)

def setUp(self):
super().setUp()
Expand Down
Loading