diff --git a/app/apps/accounts/admin.py b/app/apps/accounts/admin.py index 9aff9df..6dd8734 100644 --- a/app/apps/accounts/admin.py +++ b/app/apps/accounts/admin.py @@ -1,3 +1,58 @@ +from django import forms from django.contrib import admin -# -# No custom models registered in accounts app. The legacy Account model has been removed. \ No newline at end of file +from django.utils import timezone +from unfold.admin import ModelAdmin + +from .models import ErasureRequest + + +class ErasureRequestAdminForm(forms.ModelForm): + class Meta: + model = ErasureRequest + fields = "__all__" + + def clean(self): + cleaned = super().clean() + status = cleaned.get("status") + decision_reason = (cleaned.get("decision_reason") or "").strip() + if status in {ErasureRequest.Status.DENIED, ErasureRequest.Status.PROCESSED} and not decision_reason: + raise forms.ValidationError("Decision reason is required for denied or processed requests.") + return cleaned + + +@admin.register(ErasureRequest) +class ErasureRequestAdmin(ModelAdmin): + form = ErasureRequestAdminForm + list_display = ("id", "user", "status", "requested_at", "decided_at", "processed_at") + list_filter = ("status", "requested_at", "processed_at") + search_fields = ("user__username", "user__email") + readonly_fields = ("requested_at", "decided_at", "processed_at", "decided_by", "processed_by") + fieldsets = ( + ( + "Request", + { + "fields": ("user", "reason", "status", "requested_at"), + }, + ), + ( + "Decision", + { + "fields": ("decision_reason", "decided_by", "decided_at"), + }, + ), + ( + "Processing", + { + "fields": ("processed_by", "processed_at"), + }, + ), + ) + + def save_model(self, request, obj, form, change) -> None: + if obj.status == ErasureRequest.Status.PROCESSED: + obj.process(request.user, decision_reason=obj.decision_reason) + return + if obj.status == ErasureRequest.Status.DENIED and not obj.decided_at: + obj.decided_at = timezone.now() + obj.decided_by = request.user + super().save_model(request, obj, form, change) diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index e69de29..2834069 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -0,0 +1,16 @@ +from django import forms + + +class ErasureRequestForm(forms.Form): + reason = forms.CharField( + label="Reason for erasure request", + widget=forms.Textarea( + attrs={ + "rows": 4, + "placeholder": "Explain why you are requesting data erasure.", + "class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600", + } + ), + min_length=10, + max_length=2000, + ) diff --git a/app/apps/accounts/migrations/0006_erasure_request.py b/app/apps/accounts/migrations/0006_erasure_request.py new file mode 100644 index 0000000..2e69f04 --- /dev/null +++ b/app/apps/accounts/migrations/0006_erasure_request.py @@ -0,0 +1,75 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0005_unique_user_email_index"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ErasureRequest", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("reason", models.TextField()), + ( + "status", + models.CharField( + choices=[("pending", "Pending"), ("denied", "Denied"), ("processed", "Processed")], + db_index=True, + default="pending", + max_length=16, + ), + ), + ("requested_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ("decided_at", models.DateTimeField(blank=True, null=True)), + ("decision_reason", models.TextField(blank=True)), + ("processed_at", models.DateTimeField(blank=True, null=True)), + ( + "decided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="erasure_decisions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "processed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="erasure_processes", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="erasure_requests", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Erasure request", + "verbose_name_plural": "Erasure requests", + "ordering": ["-requested_at"], + }, + ), + migrations.AddIndex( + model_name="erasurerequest", + index=models.Index(fields=["status", "requested_at"], name="accounts_erasure_status_idx"), + ), + migrations.AddIndex( + model_name="erasurerequest", + index=models.Index(fields=["user", "status"], name="accounts_erasure_user_status_idx"), + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index e5cbb8f..4df62d9 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -1,3 +1,125 @@ -from django.db import models -# -# Legacy Account model has been removed. This app now contains URLs/views only. +from __future__ import annotations + +import uuid + +from django.conf import settings +from django.db import models, transaction +from django.utils import timezone + + +class ErasureRequest(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + DENIED = "denied", "Denied" + PROCESSED = "processed", "Processed" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="erasure_requests", + ) + reason = models.TextField() + status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True) + requested_at = models.DateTimeField(default=timezone.now, editable=False) + decided_at = models.DateTimeField(null=True, blank=True) + decided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="erasure_decisions", + ) + decision_reason = models.TextField(blank=True) + processed_at = models.DateTimeField(null=True, blank=True) + processed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="erasure_processes", + ) + + class Meta: + verbose_name = "Erasure request" + verbose_name_plural = "Erasure requests" + ordering = ["-requested_at"] + indexes = [ + models.Index(fields=["status", "requested_at"], name="accounts_erasure_status_idx"), + models.Index(fields=["user", "status"], name="accounts_erasure_user_status_idx"), + ] + + def __str__(self) -> str: + return f"Erasure request #{self.id} ({self.user_id})" + + def process(self, admin_user, decision_reason: str = "") -> None: + if self.status == self.Status.PROCESSED: + return + now = timezone.now() + with transaction.atomic(): + self._anonymize_user(admin_user, now) + self.status = self.Status.PROCESSED + self.decided_at = now + self.decided_by = admin_user + self.decision_reason = (decision_reason or "").strip() + self.processed_at = now + self.processed_by = admin_user + self.save( + update_fields=[ + "status", + "decided_at", + "decided_by", + "decision_reason", + "processed_at", + "processed_by", + ] + ) + + def _anonymize_user(self, admin_user, now) -> None: + from guardian.models import UserObjectPermission + + from apps.access.models import AccessRequest + from apps.keys.models import SSHKey + + user = self.user + token = uuid.uuid4().hex + anonymous_username = f"erased-{token}" + anonymous_email = f"{anonymous_username}@erased.local" + + user.username = anonymous_username + user.email = anonymous_email + user.first_name = "" + user.last_name = "" + user.is_active = False + user.is_staff = False + user.is_superuser = False + user.last_login = None + user.set_unusable_password() + user.save( + update_fields=[ + "username", + "email", + "first_name", + "last_name", + "is_active", + "is_staff", + "is_superuser", + "last_login", + "password", + ] + ) + + user.groups.clear() + user.user_permissions.clear() + UserObjectPermission.objects.filter(user=user).delete() + + SSHKey.objects.filter(user=user, is_active=True).update(is_active=False, revoked_at=now) + AccessRequest.objects.filter(requester=user).update(reason="[redacted]") + AccessRequest.objects.filter( + requester=user, + status__in=[AccessRequest.Status.PENDING, AccessRequest.Status.APPROVED], + ).update( + status=AccessRequest.Status.REVOKED, + decided_at=now, + decided_by=admin_user, + expires_at=now, + ) diff --git a/app/apps/accounts/templates/accounts/profile.html b/app/apps/accounts/templates/accounts/profile.html index 0614c60..6fa5e3c 100644 --- a/app/apps/accounts/templates/accounts/profile.html +++ b/app/apps/accounts/templates/accounts/profile.html @@ -45,5 +45,60 @@ -{% endblock %} +
+ Submit a GDPR erasure request to anonymize your account data. An administrator + must review and approve the request before processing. +
+ + {% if erasure_request %} ++ Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}. + {% if erasure_request.decision_reason %} + Reason: {{ erasure_request.decision_reason }} + {% endif %} +
+ {% endif %} + {% if erasure_request.status == "processed" %} ++ Your account has been anonymized. Access has been revoked and SSH keys disabled. +
+ {% endif %} +