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

Improvements to GDPR/CCPA deletion handling #8

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions app/repositories/patcher_token_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Any

from pydantic import BaseModel

import app.state


class PatcherTokenLog(BaseModel):
id: str
score_id: int
client_hash: str
commentary: str | None
token_generated_at: datetime
created_at: datetime
updated_at: datetime


READ_PARAMS = """\
id, score_id, client_hash, commentary, token_generated_at, created_at, updated_at
"""


async def delete_many_by_user_id_via_scores_tables(
user_id: int,
/,
) -> list[PatcherTokenLog]:
query = f"""\
WITH score_ids AS (
SELECT id FROM scores WHERE user_id = :user_id
UNION
SELECT id FROM scores_relax WHERE user_id = :user_id
UNION
SELECT id FROM scores_ap WHERE user_id = :user_id
)
SELECT {READ_PARAMS}
FROM patcher_token_logs
WHERE score_id IN score_ids
"""
params: dict[str, Any] = {"user_id": user_id}
recs = await app.state.database.fetch_all(query, params)
if not recs:
return []

query = """\
WITH score_ids AS (
SELECT id FROM scores WHERE user_id = :user_id
UNION
SELECT id FROM scores_relax WHERE user_id = :user_id
UNION
SELECT id FROM scores_ap WHERE user_id = :user_id
)
DELETE FROM patcher_token_logs
WHERE score_id IN score_ids
"""
params = {"user_id": user_id}
await app.state.database.execute(query, params)

return [
PatcherTokenLog(
id=rec["id"],
score_id=rec["score_id"],
client_hash=rec["client_hash"],
commentary=rec["commentary"],
token_generated_at=rec["token_generated_at"],
created_at=rec["created_at"],
updated_at=rec["updated_at"],
)
for rec in recs
]
76 changes: 76 additions & 0 deletions app/repositories/score_submission_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from datetime import datetime
from datetime import time
from typing import Any

from pydantic import BaseModel

import app.state


class ScoreSubmissionLog(BaseModel):
id: str
score_id: int
uninstall_id_hash: str
disk_signature_hash: str
client_version: str
client_hash: str
score_time_elapsed: time
osu_auth_token: str | None
created_at: datetime


READ_PARAMS = """\
id, score_id, uninstall_id_hash, disk_signature_hash, client_version,
client_hash, score_time_elapsed, osu_auth_token, created_at
"""


async def delete_many_by_user_id_via_scores_tables(
user_id: int,
/,
) -> list[ScoreSubmissionLog]:
query = f"""\
WITH score_ids AS (
SELECT id FROM scores WHERE user_id = :user_id
UNION
SELECT id FROM scores_relax WHERE user_id = :user_id
UNION
SELECT id FROM scores_ap WHERE user_id = :user_id
)
SELECT {READ_PARAMS}
FROM score_submission_logs
WHERE score_id IN score_ids
"""
params: dict[str, Any] = {"user_id": user_id}
recs = await app.state.database.fetch_all(query, params)
if not recs:
return []

query = """\
WITH score_ids AS (
SELECT id FROM scores WHERE user_id = :user_id
UNION
SELECT id FROM scores_relax WHERE user_id = :user_id
UNION
SELECT id FROM scores_ap WHERE user_id = :user_id
)
DELETE FROM score_submission_logs
WHERE score_id IN score_ids
"""
params = {"user_id": user_id}
await app.state.database.execute(query, params)

return [
ScoreSubmissionLog(
id=rec["id"],
score_id=rec["score_id"],
uninstall_id_hash=rec["uninstall_id_hash"],
disk_signature_hash=rec["disk_signature_hash"],
client_version=rec["client_version"],
client_hash=rec["client_hash"],
score_time_elapsed=rec["score_time_elapsed"],
osu_auth_token=rec["osu_auth_token"],
created_at=rec["created_at"],
)
for rec in recs
]
25 changes: 16 additions & 9 deletions app/usecases/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from app.repositories import clans
from app.repositories import lastfm_flags
from app.repositories import password_recovery
from app.repositories import patcher_token_logs
from app.repositories import score_submission_logs
from app.repositories import user_badges
from app.repositories import user_hwid_associations
from app.repositories import user_ip_associations
Expand Down Expand Up @@ -212,26 +214,26 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error:
# - [leave as-is] users_stats
# - [leave as-is] rx_stats
# - [leave as-is] ap_stats
# - [TODO/AC] ip_user
# - [TODO/AC] hw_user
# - [delete for now; TODO anonymize] ip_user
# - [delete for now; TODO anonymize] hw_user
# - [leave as-is] user_badges
# - [leave as-is] user_tourmnt_badges
# - [leave as-is] user_achievements
# - [transfer perms if owner & kick] clans
# - [leave as-is] identity_tokens
# - [leave as-is] irc_tokens
# - [TODO/AC] lastfm_flags
# - [delete] lastfm_flags
# - [leave as-is] beatmaps_rating
# - [leave as-is] clan_requests (empty?)
# - [leave as-is] comments
# - [leave as-is] matches
# - [leave as-is] match_events
# - [leave as-is] match_games
# - [leave as-is] match_game_scores
# - [TODO/financial] notifications
# - [leave-as-is (FINANCE)] notifications
# - [delete; key'd by username??] password_recovery
# - [TODO/AC] patcher_detections
# - [TODO/AC] patcher_token_logs
# - [delete] patcher_detections
# - [delete] patcher_token_logs
# - [TODO] profile_backgrounds (and filesystem data)
# - [TODO] rap_logs
# - [leave as-is] remember
Expand All @@ -244,7 +246,7 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error:
# - [leave as-is] scores_ap
# - [leave as-is] scores_relax
# - [leave as-is] scores_first
# - [TODO/AC] score_submission_logs
# - [delete] score_submission_logs
# - [leave as-is] tokens
# - [leave as-is] user_relationships
# - [leave as-is] user_beatmaps
Expand All @@ -256,6 +258,8 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error:
# misc.
# - [anonymize] replay data for all scores
# - [TODO] youtube uploads
# - [TODO] static content (screenshots, profile bgs, etc.)
# - [TODO] database/fs backups older than 50 days

# PII to focus on:
# - username / username aka
Expand Down Expand Up @@ -302,7 +306,10 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error:
await user_ip_associations.delete_many_by_user_id(user_id)
await user_hwid_associations.delete_many_by_user_id(user_id)
await lastfm_flags.delete_many_by_user_id(user_id)
# TODO: patcher_detections & patcher_token_logs

await score_submission_logs.delete_many_by_user_id_via_scores_tables(user_id)
await patcher_token_logs.delete_many_by_user_id_via_scores_tables(user_id)
# TODO: delete user records from `patcher_detections` table as well

# TODO: wipe or anonymize all replay data.
# probably a good idea to call scores-service
Expand All @@ -315,7 +322,7 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error:
# at the usecase layer
await users.anonymize_one_by_user_id(user_id)

# TODO: (technically required) anonymize data in data backups
# TODO: don't store backups older than 50 days via sql-backup-job

# inform other systems of the user's deletion (or "ban")
await app.state.redis.publish("peppy:ban", str(user_id))
Expand Down