Skip to content

Commit b4bf211

Browse files
author
juancarlos.cavero
committed
fix(cloud): close the gaps the account-deletion review turned up
Eight findings from re-reading the erasure path, each one something that would have shown up in production rather than in a test: - The local purge missed two stores. SaaSShorts outputs (output/saas_<id>) and generated thumbnails record ownership in their own in-memory dicts, not in the .owner file, so neither was erased. Thumbnails are the serious half: the hourly sweep deliberately skips their directory, so a deleted user's generated images stayed on disk forever, publicly served at /thumbnails/. - The purge ran on the event loop. rmtree over gigabytes of video stalled every other request on the process; it goes through asyncio.to_thread now. - Paths from those dicts are now resolved through _safe_under, so a session id or output_dir containing ".." deletes nothing. - Deleting a user made a webhook path reachable that never was: _apply_topup takes the user id from Stripe metadata, so a top-up completing around the deletion inserted a row pointing at a user that no longer exists. The FK violation 500s the webhook, and since the event is only recorded after handle_event returns, Stripe retries the same doomed insert for three days. It confirms the row exists first. - The "why are you leaving" box was free text stored in a record that deliberately outlives the account, which is how personal data ends up in one by accident. It is a closed list now, which also makes twenty deletions a month countable instead of readable. - Two concurrent deletes wrote two erasure records and sent two goodbye emails. The DELETE's rowcount now decides which call is the real one; the UI holds a ref so a double click doesn't fire a second Stripe cancel and R2 purge. - The delete button armed itself when the confirm box was empty, if the page ever rendered before /api/me resolved. - The goodbye email claimed we keep "a one-way hash of your email and nothing else", which was not true of the Stripe reference beside it. Fixed in the email and in the privacy policy, EN and ES. Also: skip the Upload-Post call when no managed key is configured, and say plainly in the in-flight comment that it covers metered work only. tests/test_account_local_purge.py covers tenant isolation across all three stores and both traversal attempts; test_account_erasure.py gains the confirmation gate, the API-key refusal, and the already-cancelled-at-Stripe case that would otherwise trap a user forever.
1 parent 1d9fef7 commit b4bf211

10 files changed

Lines changed: 477 additions & 47 deletions

File tree

‎CLAUDE.md‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,22 @@ added after a table shipped exists in the models but not in production.
217217
`tests/test_account_erasure.py` fails if a new table references `users.id`
218218
without joining that list.
219219

220+
`app.py` registers a callback for the local working files, which record
221+
ownership three different ways: the `.owner` file clip jobs write (so jobs
222+
recovered from disk after a restart count too), `saas_jobs`, and
223+
`thumbnail_sessions`. That last one is the only thing that ever deletes
224+
generated thumbnails: the hourly sweep skips their directory and they are
225+
served publicly at `/thumbnails/`.
226+
220227
What deliberately survives: the Stripe customer and its invoices (6-year
221228
retention, Spanish commercial law) and one `account_deletions` row holding a
222229
sha256 of the email as proof the erasure happened, itself purged after 5 years.
223-
`app.py` registers a callback so the local `output/` and `uploads/` working
224-
files go too, instead of waiting for the hourly sweep.
230+
The "why are you leaving" answer is a closed list (`DELETION_REASONS`), never
231+
free text — anything the user could type would land in a row designed to
232+
outlive them. Deleting users also made one webhook path reachable that never
233+
was before: `_apply_topup` reads the user id from Stripe metadata, so it now
234+
confirms the row still exists before inserting, or the FK violation makes
235+
Stripe retry the same doomed event for three days.
225236

226237
### Concurrency Model
227238
Async job queue with semaphore-based concurrency control. Configure via `MAX_CONCURRENT_JOBS` env var (default: 5). Jobs auto-cleanup after 1 hour.

‎app.py‎

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,45 +1218,99 @@ async def _settle_reservation(job_id):
12181218
except Exception as e:
12191219
print(f"⚠️ Reservation settle error for {job_id}: {e}")
12201220

1221+
def _owned_by(record, uid: str) -> bool:
1222+
owner = record.get('user_id') if isinstance(record, dict) else None
1223+
return owner is not None and str(owner) == uid
1224+
1225+
1226+
def _rm_under(base_dir: str, relative: str):
1227+
"""Remove a file or directory, refusing anything that escapes ``base_dir``."""
1228+
target = _safe_under(base_dir, relative)
1229+
if not target or target == os.path.realpath(base_dir):
1230+
return
1231+
if os.path.isdir(target):
1232+
shutil.rmtree(target, ignore_errors=True)
1233+
else:
1234+
try:
1235+
os.remove(target)
1236+
except OSError:
1237+
pass
1238+
1239+
12211240
def _purge_local_jobs_for_user(user_id) -> int:
1222-
"""Delete this user's job dirs, source uploads and in-memory job records.
1241+
"""Delete this user's working files and in-memory records from local disk.
12231242
12241243
Called by cloud/account.py when an account is erased. The durable copies
12251244
live on R2 and are deleted there; these are the working files on the API's
12261245
own disk, which would otherwise sit around until the one-hour cleanup sweep
1227-
— a long time to keep the videos of someone who just asked to be forgotten.
1246+
— and thumbnails not even then, because that sweep skips their directory.
1247+
1248+
Three stores, because each records ownership differently:
1249+
- clip jobs: the ``.owner`` file every managed job writes, so jobs
1250+
recovered from disk after a restart (no in-memory record) count too;
1251+
- SaaSShorts jobs (``output/saas_<id>``): ``saas_jobs`` only, no marker
1252+
file, so a restart loses the link and those age out on the sweep;
1253+
- thumbnail sessions (``output/thumbnails/<id>`` plus the source video in
1254+
``uploads/``): likewise in-memory only.
12281255
1229-
Ownership comes from the ``.owner`` file each managed job writes, so jobs
1230-
recovered from disk after a restart (no in-memory record) are covered too.
1256+
Blocking: rmtree over gigabytes of video. Callers must run it in a thread.
12311257
"""
12321258
uid = str(user_id)
1233-
job_ids = {jid for jid, job in list(jobs.items())
1234-
if job.get('user_id') is not None and str(job['user_id']) == uid}
1259+
removed = 0
1260+
1261+
job_ids = {jid for jid, job in list(jobs.items()) if _owned_by(job, uid)}
1262+
thumbs_dir_name = os.path.basename(THUMBNAILS_DIR)
12351263
try:
12361264
entries = os.listdir(OUTPUT_DIR)
12371265
except OSError:
12381266
entries = []
12391267
for job_id in entries:
1240-
owner_path = os.path.join(OUTPUT_DIR, job_id, ".owner")
1268+
# Never a job, and it backs a StaticFiles mount: deleting the directory
1269+
# itself 500s every /thumbnails request until the process restarts.
1270+
if job_id == thumbs_dir_name:
1271+
continue
12411272
try:
1242-
with open(owner_path) as f:
1273+
with open(os.path.join(OUTPUT_DIR, job_id, ".owner")) as f:
12431274
if f.read().strip() == uid:
12441275
job_ids.add(job_id)
12451276
except OSError:
12461277
continue
12471278

12481279
for job_id in job_ids:
1249-
shutil.rmtree(os.path.join(OUTPUT_DIR, job_id), ignore_errors=True)
1280+
_rm_under(OUTPUT_DIR, job_id)
12501281
jobs.pop(job_id, None)
12511282
# Source uploads are named "<job_id>_<filename>" (see /api/process).
12521283
for path in glob.glob(os.path.join(UPLOAD_DIR, f"{glob.escape(job_id)}_*")):
12531284
try:
12541285
os.remove(path)
12551286
except OSError:
12561287
pass
1257-
if job_ids:
1258-
print(f"🗑️ Purged {len(job_ids)} local job dir(s) for erased user {uid}.")
1259-
return len(job_ids)
1288+
removed += 1
1289+
1290+
for jid, job in list(saas_jobs.items()):
1291+
if not _owned_by(job, uid):
1292+
continue
1293+
out = job.get('output_dir')
1294+
if out:
1295+
_rm_under(OUTPUT_DIR, os.path.basename(out))
1296+
saas_jobs.pop(jid, None)
1297+
removed += 1
1298+
1299+
for sid, sess in list(thumbnail_sessions.items()):
1300+
if not _owned_by(sess, uid):
1301+
continue
1302+
# Generated thumbnails are served publicly at /thumbnails/<id>/... and
1303+
# nothing else ever deletes them.
1304+
_rm_under(THUMBNAILS_DIR, sid)
1305+
video_path = sess.get('video_path')
1306+
if video_path:
1307+
_rm_under(UPLOAD_DIR, os.path.basename(video_path))
1308+
thumbnail_sessions.pop(sid, None)
1309+
removed += 1
1310+
1311+
if removed:
1312+
print(f"🗑️ Purged {removed} local work item(s) for erased user {uid}.")
1313+
return removed
12601314

12611315

12621316
@asynccontextmanager

‎cloud/account.py‎

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
2323
What deliberately survives: the Stripe customer and its invoices (six-year
2424
retention under Spanish commercial law, privacy policy §5) and one
25-
[AccountDeletion] row holding a sha256 of the email — proof the erasure
26-
happened, with no readable identity in it.
25+
[AccountDeletion] row proving the erasure happened — a sha256 of the email
26+
rather than the address, and no free text anywhere in it. The confirmation
27+
email tells the user both, because a record they were never told about is not
28+
accountability, it is retention.
2729
"""
2830
import asyncio
2931
import hashlib
@@ -56,6 +58,17 @@
5658
Subscription, ApiKey, SignupAttribution, UploadPostProfile,
5759
)
5860

61+
# The optional "why are you leaving" answer, as a closed list. It was a free
62+
# text box first, and that was wrong: whatever the user types lands in a record
63+
# that deliberately outlives their account, so a single "my name is X, please
64+
# confirm" turns the one row meant to hold no readable identity into retained
65+
# personal data. A fixed vocabulary cannot do that, and twenty deletions a month
66+
# are far more legible counted than read.
67+
DELETION_REASONS = (
68+
"too_expensive", "not_using_it", "clip_quality", "missing_feature",
69+
"found_alternative", "privacy", "other",
70+
)
71+
5972
router = APIRouter()
6073

6174
# Job dirs on the API's own disk belong to app.py, which cannot be imported from
@@ -141,6 +154,8 @@ async def _delete_upload_post_profile(user_id):
141154
import httpx
142155
from .social_profiles import API_BASE, _auth_headers
143156

157+
if not settings.managed_upload_post_key:
158+
return
144159
async with database.session() as session:
145160
prof = await session.get(UploadPostProfile, user_id)
146161
if prof is None:
@@ -160,16 +175,31 @@ async def _delete_upload_post_profile(user_id):
160175
print(f"⚠️ Upload-Post profile delete failed for {username}: {e}")
161176

162177

163-
async def _erase_rows(user_id, email, stripe_customer_id, plan, r2_deleted, reason):
178+
async def _erase_rows(user_id, email, stripe_customer_id, plan, r2_deleted, reason) -> bool:
164179
"""Swap the account for its erasure record, in one transaction.
165180
166181
All-or-nothing on purpose: a half-erased account is worse than an un-erased
167182
one, because the user is told they are gone while some of their rows are
168183
not. Magic-link tokens are keyed by email rather than user id, so they need
169184
their own statement on top of [USER_OWNED_TABLES].
185+
186+
Returns whether this call is the one that removed the account. Two requests
187+
can race here — a double-click, or two tabs — and everything before this
188+
point is idempotent, but the erasure record is not: writing it from the
189+
loser would leave two rows for one deletion, possibly disagreeing about the
190+
reason. Postgres serialises the two DELETEs, so the loser sees rowcount 0
191+
and writes nothing.
170192
"""
171193
async with database.session() as session:
172194
async with session.begin():
195+
for model in USER_OWNED_TABLES:
196+
await session.execute(
197+
delete(model).where(model.user_id == user_id))
198+
await session.execute(
199+
delete(MagicLinkToken).where(MagicLinkToken.email == email))
200+
result = await session.execute(delete(User).where(User.id == user_id))
201+
if result.rowcount == 0:
202+
return False
173203
session.add(AccountDeletion(
174204
former_user_id=str(user_id),
175205
email_sha256=email_fingerprint(email),
@@ -178,12 +208,7 @@ async def _erase_rows(user_id, email, stripe_customer_id, plan, r2_deleted, reas
178208
r2_objects_deleted=r2_deleted,
179209
reason=reason,
180210
))
181-
for model in USER_OWNED_TABLES:
182-
await session.execute(
183-
delete(model).where(model.user_id == user_id))
184-
await session.execute(
185-
delete(MagicLinkToken).where(MagicLinkToken.email == email))
186-
await session.execute(delete(User).where(User.id == user_id))
211+
return True
187212

188213

189214
# --------------------------------------------------------------------------- #
@@ -194,7 +219,7 @@ class DeleteAccountRequest(BaseModel):
194219
# there is no password to re-enter, so this is the strongest thing we can
195220
# ask for without mailing a second token to a user who is leaving anyway.
196221
confirm_email: str
197-
reason: Optional[str] = None
222+
reason: Optional[str] = None # one of DELETION_REASONS, or ignored
198223

199224

200225
@router.delete("/api/account")
@@ -211,10 +236,15 @@ async def delete_account(payload: DeleteAccountRequest, request: Request):
211236
if row is None:
212237
raise HTTPException(status_code=404, detail="Account not found.")
213238
email, stripe_customer_id = row.email, row.stripe_customer_id
214-
# A reserved ledger row means a render is in flight. Erasing now would
215-
# delete the output from under a job that is still writing it, and the
239+
# A reserved ledger row means metered work is in flight. Erasing now
240+
# would pull the output from under a job still writing it, and the
216241
# reservation would never settle. Minutes are refunded on failure, so
217242
# waiting costs the user nothing.
243+
#
244+
# This covers everything that costs minutes, which is everything long
245+
# enough to matter. A free action (burning captions on an untranslated
246+
# clip) writes no ledger row and so is not covered: worst case it fails
247+
# on a missing file, in a tab whose session is about to end anyway.
218248
in_flight = (await session.execute(
219249
select(func.count(UsageLedger.id)).where(
220250
UsageLedger.user_id == user.id, UsageLedger.status == "reserved")
@@ -249,15 +279,20 @@ async def delete_account(payload: DeleteAccountRequest, request: Request):
249279

250280
if _local_purge is not None:
251281
try:
252-
_local_purge(user.id)
282+
# rmtree over gigabytes of video: off the event loop, or every other
283+
# request on the process waits for this user's disk to clear.
284+
await asyncio.to_thread(_local_purge, user.id)
253285
except Exception as e:
254286
print(f"⚠️ Local job purge failed for {user.id}: {e}")
255287

256288
await _delete_upload_post_profile(user.id)
257289

258-
reason = (payload.reason or "").strip()[:500] or None
290+
# Anything not in the closed list is dropped rather than rejected: a
291+
# mismatched client must not be able to fail a deletion over a label.
292+
reason = payload.reason if payload.reason in DELETION_REASONS else None
259293
try:
260-
await _erase_rows(user.id, email, stripe_customer_id, plan, r2_deleted, reason)
294+
erased = await _erase_rows(
295+
user.id, email, stripe_customer_id, plan, r2_deleted, reason)
261296
except Exception as e:
262297
# The content is already gone; only the account row survived. Say that
263298
# rather than a bare 500 — the user needs to know a retry is safe and
@@ -269,6 +304,10 @@ async def delete_account(payload: DeleteAccountRequest, request: Request):
269304
# ``email`` was read before the delete on purpose: from here on the address
270305
# exists nowhere in our systems, and the goodbye email still has to go out.
271306

307+
if not erased:
308+
# A concurrent request got there first and has already sent the email.
309+
return {"deleted": True, "r2_objects_deleted": r2_deleted}
310+
272311
print(f"🗑️ Account erased: {user.id} (plan={plan}, r2_objects={r2_deleted})")
273312

274313
# Everything below is after-the-fact and must never fail the deletion.

‎cloud/billing.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,20 @@ async def _apply_topup(session_obj: dict):
282282
)).scalar_one_or_none()
283283
if existing:
284284
return
285+
# The id rides in Stripe's metadata, so it can name an account that
286+
# no longer exists: a checkout completed moments before the user
287+
# erased themselves, or any webhook Stripe retries afterwards.
288+
# Inserting it blind trips the foreign key, and because the event is
289+
# only recorded after handle_event returns, the 500 makes Stripe
290+
# retry the same doomed insert for three days.
285291
user_id = (session_obj.get("metadata") or {}).get("user_id")
292+
if user_id:
293+
try:
294+
user_id = (await s.execute(
295+
select(User.id).where(User.id == user_id)
296+
)).scalar_one_or_none()
297+
except Exception:
298+
user_id = None # not a uuid at all
286299
if not user_id:
287300
user_id = await _user_id_for_customer(s, session_obj.get("customer"))
288301
if not user_id:

‎cloud/emails.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,11 @@ async def send_account_deleted_email(email: str):
156156
<p>Two things we keep, and why:</p>
157157
<ul style="line-height:1.7;padding-left:20px">
158158
<li><strong>Your invoices</strong>, for six years &mdash; Spanish
159-
commercial law requires it.</li>
160-
<li><strong>A record that this deletion happened</strong>, holding a
161-
one-way hash of your email address and nothing else.</li>
159+
commercial law requires it, and the Stripe customer reference
160+
that finds them goes with it.</li>
161+
<li><strong>A record that this deletion happened</strong>, for five
162+
years, holding a one-way hash of your email address instead of
163+
the address itself.</li>
162164
</ul>
163165
<p>You can sign up again any time with the same address; it will be a
164166
brand-new, empty account.</p>

‎cloud/models.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,13 @@ class AccountDeletion(Base):
228228
229229
GDPR Art. 17 erasure has an accountability twin (Art. 5.2): if a former user
230230
later claims we never deleted their account, the only way to answer is a
231-
record that outlives the deletion. So this holds no readable identity — a
232-
sha256 of the account email, which can confirm "yes, this address was
233-
deleted on this date" without storing the address itself.
231+
record that outlives the deletion. The identifying field is therefore a
232+
sha256 of the account email, which confirms "yes, this address was deleted
233+
on this date" without storing the address itself.
234+
235+
``reason`` is one label from ``account.DELETION_REASONS``, never free text:
236+
anything the user could type would land in a row that deliberately outlives
237+
their account, which is the opposite of what this row is for.
234238
235239
``stripe_customer_id`` is the one exception and it is deliberate: the
236240
invoices behind it must be kept for six years under Spanish commercial law,
@@ -249,5 +253,5 @@ class AccountDeletion(Base):
249253
stripe_customer_id = Column(Text, nullable=True)
250254
plan_at_deletion = Column(String(20), nullable=True)
251255
r2_objects_deleted = Column(Integer, nullable=True)
252-
reason = Column(Text, nullable=True) # optional free-text the user typed
256+
reason = Column(String(32), nullable=True) # one of account.DELETION_REASONS
253257
deleted_at = Column(DateTime(timezone=True), server_default=func.now())

‎dashboard/seo/legal.js‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,8 @@ Delete account</strong> in the dashboard does it yourself, immediately and
613613
permanently, taking your projects, clips, transcripts, API keys and social
614614
connections with it and cancelling any active subscription. Two things survive
615615
it, both listed in section 5: your invoices, which tax law requires us to keep,
616-
and a one-way hash of your email address recording that the deletion happened.
616+
and a record that the deletion happened, which identifies you by a one-way hash
617+
of your email address rather than by the address itself.
617618
If you believe we are mishandling your data, you can complain to the Spanish
618619
supervisory authority (AEPD, aepd.es) or to the authority of your own EU
619620
country.</p>
@@ -750,8 +751,9 @@ forma inmediata y permanente, y se lleva por delante tus proyectos, clips,
750751
transcripciones, claves de API y conexiones con redes sociales, además de
751752
cancelar cualquier suscripción activa. Solo sobreviven dos cosas, ambas
752753
recogidas en el apartado 5: tus facturas, que la normativa fiscal nos obliga a
753-
conservar, y un hash irreversible de tu dirección de email que acredita que la
754-
eliminación se produjo. Si crees que tratamos mal tus datos, puedes reclamar
754+
conservar, y un registro de que la eliminación se produjo, que te identifica
755+
mediante un hash irreversible de tu dirección de email y no mediante la
756+
dirección en sí. Si crees que tratamos mal tus datos, puedes reclamar
755757
ante la Agencia Española de Protección de Datos (AEPD, aepd.es) o ante la
756758
autoridad de tu país de la UE.</p>
757759

0 commit comments

Comments
 (0)