Skip to content

Commit 45592cb

Browse files
feat(user): add authenticated data purge flow (#1108)
Co-authored-by: Darshan G K <122042809+imDarshanGK@users.noreply.github.com>
1 parent c292332 commit 45592cb

8 files changed

Lines changed: 877 additions & 0 deletions

File tree

backend/app/models.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ class User(Base):
1717
DateTime, default=lambda: datetime.now(UTC)
1818
)
1919

20+
deletion_status: Mapped[str] = mapped_column(
21+
String(32),
22+
nullable=False,
23+
default="active",
24+
server_default="active",
25+
index=True,
26+
)
27+
deletion_requested_at: Mapped[datetime | None] = mapped_column(
28+
DateTime,
29+
nullable=True,
30+
)
31+
deletion_scheduled_for: Mapped[datetime | None] = mapped_column(
32+
DateTime,
33+
nullable=True,
34+
index=True,
35+
)
36+
2037
histories = relationship(
2138
"QueryHistory", back_populates="user", cascade="all, delete-orphan"
2239
)
@@ -56,6 +73,21 @@ class FavoriteResult(Base):
5673
user = relationship("User", back_populates="favorites")
5774

5875

76+
class UserDataPurgeAudit(Base):
77+
__tablename__ = "user_data_purge_audit"
78+
79+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
80+
user_id_hash: Mapped[str] = mapped_column(String(128), index=True)
81+
email_hash: Mapped[str] = mapped_column(String(128), index=True)
82+
history_deleted: Mapped[int] = mapped_column(Integer, default=0)
83+
favorites_deleted: Mapped[int] = mapped_column(Integer, default=0)
84+
requested_at: Mapped[datetime] = mapped_column(
85+
DateTime, default=lambda: datetime.now(UTC)
86+
)
87+
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
88+
status: Mapped[str] = mapped_column(String(32), default="completed")
89+
90+
5991
class DigestSubscription(Base):
6092
__tablename__ = "digest_subscriptions"
6193

backend/app/routers/auth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
5959
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
6060
)
6161

62+
if getattr(user, "deletion_status", "active") == "pending_deletion":
63+
raise HTTPException(
64+
status_code=status.HTTP_401_UNAUTHORIZED,
65+
detail="Account is pending deletion",
66+
)
67+
6268
token = create_access_token(user.id)
6369
return AuthResponse(access_token=token, user_id=user.id, email=user.email)
6470

backend/app/routers/user_data.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,52 @@
99
FavoriteRecord,
1010
HistoryCreateRequest,
1111
HistoryRecord,
12+
UserDataPurgePreviewResponse,
13+
UserDataPurgeRequest,
14+
UserDataPurgeResponse,
1215
)
1316
from ..security import get_current_user
17+
from ..services.user_deletion import preview_user_data_purge, purge_user_data
1418

1519
router = APIRouter(prefix="/user", tags=["User Data"])
1620

1721

22+
@router.get("/data-purge/preview", response_model=UserDataPurgePreviewResponse)
23+
def preview_data_purge(
24+
current_user: User = Depends(get_current_user),
25+
db: Session = Depends(get_db),
26+
):
27+
preview = preview_user_data_purge(db, current_user)
28+
return UserDataPurgePreviewResponse(
29+
user_id=preview.user_id,
30+
history_records=preview.history_records,
31+
favorite_records=preview.favorite_records,
32+
account_will_be_deleted=preview.account_will_be_deleted,
33+
confirmation_phrase=preview.confirmation_phrase,
34+
deletion_status=preview.deletion_status,
35+
retention_days=preview.retention_days,
36+
deletion_scheduled_for=preview.deletion_scheduled_for,
37+
)
38+
39+
40+
@router.post("/data-purge", response_model=UserDataPurgeResponse)
41+
def purge_data(
42+
payload: UserDataPurgeRequest,
43+
current_user: User = Depends(get_current_user),
44+
db: Session = Depends(get_db),
45+
):
46+
result = purge_user_data(db, current_user, payload.confirmation)
47+
return UserDataPurgeResponse(
48+
status=result.status,
49+
history_deleted=result.history_deleted,
50+
favorites_deleted=result.favorites_deleted,
51+
account_deleted=result.account_deleted,
52+
audit_recorded=result.audit_recorded,
53+
deletion_scheduled_for=result.deletion_scheduled_for,
54+
retention_days=result.retention_days,
55+
)
56+
57+
1858
@router.get("/history", response_model=list[HistoryRecord])
1959
def list_history(
2060
limit: int = Query(50, ge=1, le=100),

backend/app/schemas.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
from datetime import datetime
45
from typing import Any
56

67
from pydantic import BaseModel, Field, field_validator, model_validator
@@ -709,6 +710,101 @@ class FavoriteRecord(BaseModel):
709710
)
710711

711712

713+
class UserDataPurgePreviewResponse(BaseModel):
714+
"""Preview of user-owned data that will be removed by a purge request."""
715+
716+
user_id: int = Field(
717+
...,
718+
description="Authenticated user id.",
719+
json_schema_extra={"example": 12},
720+
)
721+
history_records: int = Field(
722+
...,
723+
description="Number of saved history records that will be deleted.",
724+
json_schema_extra={"example": 4},
725+
)
726+
favorite_records: int = Field(
727+
...,
728+
description="Number of favorite records that will be deleted.",
729+
json_schema_extra={"example": 2},
730+
)
731+
account_will_be_deleted: bool = Field(
732+
...,
733+
description="Whether the user account row will be deleted.",
734+
json_schema_extra={"example": True},
735+
)
736+
confirmation_phrase: str = Field(
737+
...,
738+
description="Phrase required to confirm irreversible deletion.",
739+
json_schema_extra={"example": "DELETE MY DATA"},
740+
)
741+
deletion_status: str = Field(
742+
...,
743+
description="Current user deletion lifecycle status.",
744+
json_schema_extra={"example": "active"},
745+
)
746+
retention_days: int = Field(
747+
...,
748+
description="Number of days before final erase becomes eligible.",
749+
json_schema_extra={"example": 30},
750+
)
751+
deletion_scheduled_for: datetime | None = Field(
752+
None,
753+
description="Timestamp when final erase becomes eligible, if already scheduled.",
754+
)
755+
756+
757+
class UserDataPurgeRequest(BaseModel):
758+
"""Confirmation body for irreversible account and data purge."""
759+
760+
confirmation: str = Field(
761+
...,
762+
min_length=1,
763+
max_length=64,
764+
description="Must exactly match the confirmation phrase.",
765+
json_schema_extra={"example": "DELETE MY DATA"},
766+
)
767+
768+
769+
class UserDataPurgeResponse(BaseModel):
770+
"""Result returned after successful user data purge."""
771+
772+
status: str = Field(
773+
...,
774+
description="Purge status.",
775+
json_schema_extra={"example": "purged"},
776+
)
777+
history_deleted: int = Field(
778+
...,
779+
description="Deleted history record count.",
780+
json_schema_extra={"example": 4},
781+
)
782+
favorites_deleted: int = Field(
783+
...,
784+
description="Deleted favorite record count.",
785+
json_schema_extra={"example": 2},
786+
)
787+
account_deleted: bool = Field(
788+
...,
789+
description="Whether the user account was deleted.",
790+
json_schema_extra={"example": True},
791+
)
792+
audit_recorded: bool = Field(
793+
...,
794+
description="Whether a non-sensitive audit record was stored.",
795+
json_schema_extra={"example": True},
796+
)
797+
deletion_scheduled_for: datetime | None = Field(
798+
None,
799+
description="Timestamp when final erase becomes eligible.",
800+
)
801+
retention_days: int = Field(
802+
...,
803+
description="Number of days before final erase becomes eligible.",
804+
json_schema_extra={"example": 30},
805+
)
806+
807+
712808
# ── Share ─────────────────────────────────────────────────────────────────────
713809

714810

backend/app/security.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ def get_current_user(
9494
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
9595
)
9696

97+
if getattr(user, "deletion_status", "active") == "pending_deletion":
98+
raise HTTPException(
99+
status_code=status.HTTP_401_UNAUTHORIZED,
100+
detail="User is pending deletion",
101+
)
102+
97103
return user
98104

99105

0 commit comments

Comments
 (0)