Skip to content

Commit 92ad5f1

Browse files
authored
Merge pull request #306 from spoo-me/feat/account-deletion
feat: self-serve account deletion with scheduled erasure
2 parents e9af7d0 + ec01982 commit 92ad5f1

77 files changed

Lines changed: 7012 additions & 101 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/tests.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ jobs:
5252
matrix:
5353
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
5454

55+
# Real MongoDB for tests/db — that suite fails loudly (never skips)
56+
# when CI is set, so this container is load-bearing.
57+
services:
58+
mongo:
59+
image: mongo:8
60+
ports:
61+
- 27017:27017
62+
options: >-
63+
--health-cmd "mongosh --quiet --eval 'db.runCommand({ping: 1})'"
64+
--health-interval 5s
65+
--health-timeout 5s
66+
--health-retries 12
67+
5568
steps:
5669
- name: Checkout
5770
uses: actions/checkout@v7
@@ -70,6 +83,7 @@ jobs:
7083
- name: Run tests with coverage
7184
env:
7285
MONGODB_URI: "mongodb://localhost:27017/"
86+
MONGO_TEST_URI: "mongodb://localhost:27017/?directConnection=true"
7387
run: |
7488
uv run python -m pytest \
7589
-n auto \

config.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,39 @@ class EmailSettings(BaseSettings):
8787
zepto_from_name: str = "spoo.me"
8888

8989

90+
class PostHogErasureSettings(BaseSettings):
91+
"""PostHog person deletion for the account-erasure cascade (GDPR Art. 17).
92+
93+
Off unless both ``api_key`` and ``project_id`` are set — the cascade
94+
then skips the step via the Noop eraser. The key is a personal API key
95+
with person-deletion scope, NOT the public project key. Env vars
96+
prefixed ``POSTHOG_ERASURE_`` (same convention as ``R2_``).
97+
"""
98+
99+
model_config = SettingsConfigDict(
100+
env_file=".env",
101+
extra="ignore",
102+
env_prefix="POSTHOG_ERASURE_",
103+
)
104+
105+
api_key: str = ""
106+
project_id: str = ""
107+
host: str = "https://eu.posthog.com"
108+
109+
@field_validator("host")
110+
@classmethod
111+
def _host_must_be_https(cls, v: str) -> str:
112+
# The key is a person-deletion-scoped personal API key — a config
113+
# typo must never send it over plaintext. Fail at boot, not mid-sweep.
114+
if not v.startswith("https://"):
115+
raise ValueError("POSTHOG_ERASURE_HOST must be an https:// URL")
116+
return v
117+
118+
@property
119+
def enabled(self) -> bool:
120+
return bool(self.api_key and self.project_id)
121+
122+
90123
class LoggingSettings(BaseSettings):
91124
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
92125

@@ -694,6 +727,18 @@ def blocked_self_domains(self) -> tuple[str, ...]:
694727
max_active_api_keys: int = 20
695728
max_date_range_days: int = 90
696729
http_client_timeout: float = 5.0
730+
# Account deletion (GDPR Art. 17): days between the deletion request
731+
# and the erasure sweep purging the account (0 = purge on the next
732+
# sweep — integration smoke only), and how many due accounts one
733+
# sweep run erases (the */10 cron drains any backlog).
734+
account_deletion_grace_days: int = 7
735+
account_erasure_batch_limit: int = 25
736+
# Sweep-run budget: stop STARTING erasures past this (80% of the 600s
737+
# scheduler lease) so a batch of heavy cascades never outruns the lease.
738+
account_erasure_time_budget_seconds: int = 480
739+
# Erasure-claim lease: ERASING accounts re-claim only after this — must
740+
# exceed the sweep budget plus one heavy cascade (prod whale ~305s).
741+
account_erasure_claim_lease_seconds: int = 900
697742

698743
# Validator constraints (overridable by self-hosters via env vars)
699744
blocked_url_regex_timeout: float = 0.2
@@ -723,13 +768,24 @@ def blocked_self_domains(self) -> tuple[str, ...]:
723768
"max_emoji_alias_length",
724769
"emoji_generated_alias_length",
725770
"geo_rules_max_countries",
771+
"account_erasure_batch_limit",
772+
"account_erasure_time_budget_seconds",
773+
"account_erasure_claim_lease_seconds",
726774
)
727775
@classmethod
728776
def _must_be_positive_int(cls, v: int, info) -> int:
729777
if v < 1:
730778
raise ValueError(f"{info.field_name} must be >= 1, got {v}")
731779
return v
732780

781+
@field_validator("account_deletion_grace_days")
782+
@classmethod
783+
def _grace_days_non_negative(cls, v: int) -> int:
784+
# 0 is legal (purge on the next sweep) — negatives are not.
785+
if v < 0:
786+
raise ValueError(f"account_deletion_grace_days must be >= 0, got {v}")
787+
return v
788+
733789
@field_validator("emoji_accept_max_version", "emoji_generate_max_version")
734790
@classmethod
735791
def _emoji_version_cap_sane(cls, v: float, info) -> float:
@@ -791,6 +847,7 @@ def _password_max_length_sane(cls, v: int) -> int:
791847
safety: SafetySettings | None = None
792848
scheduler: SchedulerSettings | None = None
793849
llm: LlmSettings | None = None
850+
posthog_erasure: PostHogErasureSettings | None = None
794851

795852
@model_validator(mode="after")
796853
def _populate_sub_configs_and_secret(self) -> AppSettings:
@@ -838,6 +895,8 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
838895
self.safety = SafetySettings()
839896
if self.scheduler is None:
840897
self.scheduler = SchedulerSettings()
898+
if self.posthog_erasure is None:
899+
self.posthog_erasure = PostHogErasureSettings()
841900
if self.webhooks.enabled and not self.secret_key:
842901
# Signing secrets are encrypted with a key derived from
843902
# SECRET_KEY; an empty master would mean a predictable key.
@@ -851,6 +910,11 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
851910
"CUSTOM_DOMAINS_MOCK_DCV must not be enabled in production"
852911
)
853912

913+
# Zero grace purges on the next sweep — a smoke-test convenience
914+
# that in production would void the restore window. Refuse to boot.
915+
if self.env == "production" and self.account_deletion_grace_days < 1:
916+
raise ValueError("ACCOUNT_DELETION_GRACE_DAYS must be >= 1 in production")
917+
854918
return self
855919

856920
@property

dependencies/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
get_settings,
5454
)
5555
from dependencies.services import (
56+
AccountDeletionSvc,
5657
ApiKeySvc,
5758
AppGrantRepo,
5859
BulkUrlSvc,
@@ -78,6 +79,7 @@
7879
VerificationSvc,
7980
WebhookSvc,
8081
fetch_user_profile,
82+
get_account_deletion_service,
8183
get_api_key_service,
8284
get_app_grant_repo,
8385
get_bulk_url_service,
@@ -112,6 +114,7 @@
112114
"URL_MANAGEMENT_SCOPES",
113115
"URL_READ_SCOPES",
114116
# services (aliases)
117+
"AccountDeletionSvc",
115118
"ApiKeySvc",
116119
"AppGrantRepo",
117120
# infra
@@ -155,6 +158,7 @@
155158
"check_credential_scopes",
156159
# services (getters)
157160
"fetch_user_profile",
161+
"get_account_deletion_service",
158162
"get_api_key_service",
159163
"get_app_grant_repo",
160164
"get_app_registry",

dependencies/auth.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@
1717
from fastapi import Depends, Request
1818

1919
from dependencies.infra import get_db, get_settings
20-
from errors import AuthenticationError, EmailNotVerifiedError, ForbiddenError
20+
from errors import (
21+
AccountPendingDeletionError,
22+
AuthenticationError,
23+
EmailNotVerifiedError,
24+
ForbiddenError,
25+
)
2126
from infrastructure.crypto import hash_token
2227
from infrastructure.logging import get_logger
2328
from repositories.api_key_repository import ApiKeyRepository
2429
from repositories.user_repository import UserRepository
2530
from schemas.dto.requests.api_key import ApiKeyScope
2631
from schemas.models.api_key import ApiKeyDoc
32+
from schemas.models.user import UserStatus
2733
from shared.datetime_utils import as_aware_utc
2834

2935
log = get_logger(__name__)
@@ -76,7 +82,11 @@ async def get_current_user(
7682
3. access_token cookie → JWT path
7783
4. None → anonymous
7884
79-
Returns None for anonymous requests; never raises.
85+
Returns None for anonymous requests. Never raises for malformed or
86+
invalid credentials — the one exception is a VALID API key whose
87+
account is pending deletion, which raises
88+
``AccountPendingDeletionError`` (403 + ``X-Error-Code``) so keys go
89+
dark for the grace window instead of degrading to anonymous.
8090
"""
8191
settings = get_settings(request)
8292
jwt_cfg = settings.jwt
@@ -129,6 +139,23 @@ async def get_current_user(
129139
except Exception:
130140
return None
131141

142+
# Keys go dark during the deletion grace window — same error
143+
# surface as the login gate (INACTIVE stays ungated: login parity).
144+
if user is not None and user.status in (
145+
UserStatus.PENDING_DELETION,
146+
UserStatus.ERASING,
147+
):
148+
log.warning(
149+
"api_key_auth_blocked",
150+
reason="pending_deletion",
151+
key_prefix=key.token_prefix,
152+
key_id=str(key.id),
153+
user_id=str(key.user_id),
154+
)
155+
raise AccountPendingDeletionError(
156+
"this account is scheduled for deletion"
157+
)
158+
132159
# Best-effort last-used stamp — debounced so a busy key costs at
133160
# most one extra write per hour, and never fails the request.
134161
last_used = as_aware_utc(key.last_used_at)

dependencies/services.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from repositories.app_grant_repository import AppGrantRepository
1818
from repositories.user_repository import UserRepository
1919
from schemas.models.user import UserDoc
20+
from services.account_deletion_service import AccountDeletionService
2021
from services.api_key_service import ApiKeyService
2122
from services.auth.credentials import CredentialService
2223
from services.auth.device import DeviceAuthService
@@ -95,6 +96,10 @@ def get_user_repo(request: Request) -> UserRepository:
9596
return request.app.state.user_repo
9697

9798

99+
def get_account_deletion_service(request: Request) -> AccountDeletionService:
100+
return request.app.state.account_deletion_service
101+
102+
98103
async def fetch_user_profile(user_repo: UserRepository, user_id: ObjectId) -> UserDoc:
99104
"""Fetch a user by ID or raise NotFoundError.
100105
@@ -174,6 +179,9 @@ def get_webhook_service(request: Request) -> WebhookService:
174179
PasswordSvc = Annotated[PasswordService, Depends(get_password_service)]
175180
DeviceAuthSvc = Annotated[DeviceAuthService, Depends(get_device_auth_service)]
176181
UserRepo = Annotated[UserRepository, Depends(get_user_repo)]
182+
AccountDeletionSvc = Annotated[
183+
AccountDeletionService, Depends(get_account_deletion_service)
184+
]
177185
OAuthSvc = Annotated[OAuthService, Depends(get_oauth_service)]
178186
ProfilePictureSvc = Annotated[
179187
ProfilePictureService, Depends(get_profile_picture_service)

0 commit comments

Comments
 (0)