Skip to content

Commit

Permalink
Automated flow for GDPR/CCPA deletion requests (#7)
Browse files Browse the repository at this point in the history
* Draft: Work towards improved GDPR/CCPA compliance

* ac assoc repos

* todo

* txn and logs

* 204 resp

* lastfm flags

* redis conn

* dep

* reformat some code

* add routing

* wipe notes and add dt's

* peppy:ban pubsub

* error resp

* fixes & wipe more user data

* type fixes

* note

* reduce map

* typo

* take over prod
  • Loading branch information
cmyui authored Oct 25, 2024
1 parent bbce698 commit 3b0fe85
Show file tree
Hide file tree
Showing 25 changed files with 683 additions and 26 deletions.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ DB_HOST=localhost
DB_PORT=3306
DB_NAME=akatsuki

REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=default
REDIS_PASS=
REDIS_DB=0

AWS_S3_ENDPOINT_URL=
AWS_S3_REGION_NAME=
AWS_S3_BUCKET_NAME=
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/production-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- master
- gdpr-ccpa-compliance

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down
3 changes: 2 additions & 1 deletion app/api/authorization.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from app import security
from app.errors import Error, ErrorCode
from app.errors import Error
from app.errors import ErrorCode
from app.repositories import access_tokens


Expand Down
4 changes: 4 additions & 0 deletions app/api/internal/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from fastapi import APIRouter

from app.api.internal.v1 import users

v1_router = APIRouter()

v1_router.include_router(users.router)
42 changes: 42 additions & 0 deletions app/api/internal/v1/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import logging

from fastapi import APIRouter
from fastapi import Response
from fastapi import status

from app.api.responses import JSONResponse
from app.errors import Error
from app.errors import ErrorCode
from app.usecases import users

router = APIRouter(tags=["(Internal) Users API"])


def map_error_code_to_http_status_code(error_code: ErrorCode) -> int:
status_code = _error_code_to_http_status_code_map.get(error_code)
if status_code is None:
logging.warning(
"No HTTP status code mapping found for error code: %s",
error_code,
extra={"error_code": error_code},
)
return 500
return status_code


_error_code_to_http_status_code_map: dict[ErrorCode, int] = {
ErrorCode.INTERNAL_SERVER_ERROR: 500,
}


@router.delete("/api/v1/users/{user_id}")
async def delete_user(user_id: int) -> Response:
response = await users.delete_one_by_user_id(user_id)
if isinstance(response, Error):
return JSONResponse(
content=response.model_dump(),
status_code=map_error_code_to_http_status_code(response.error_code),
)

# TODO: check if response body is b"none"
return Response(status_code=status.HTTP_204_NO_CONTENT)
2 changes: 1 addition & 1 deletion app/api/public/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from . import authentication
from . import overall_stats
from . import users
from . import user_stats
from . import users

public_router = APIRouter()

Expand Down
5 changes: 3 additions & 2 deletions app/api/public/authentication.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Cookie
from fastapi import APIRouter
from fastapi import Cookie
from fastapi import Header
from fastapi import Response
from pydantic import BaseModel
Expand Down Expand Up @@ -78,7 +79,7 @@ async def logout(
return JSONResponse(
content=trusted_access_token.model_dump(),
status_code=map_error_code_to_http_status_code(
trusted_access_token.error_code
trusted_access_token.error_code,
),
)

Expand Down
6 changes: 4 additions & 2 deletions app/api/public/overall_stats.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from fastapi import APIRouter, Response
from fastapi import APIRouter
from fastapi import Response
from fastapi.responses import JSONResponse
from app.usecases import user_stats, users

from app.usecases import user_stats
from app.usecases import users

router = APIRouter(tags=["(Public) Overall Stats API"])

Expand Down
11 changes: 6 additions & 5 deletions app/api/public/user_stats.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from fastapi import APIRouter
from fastapi import Response
from fastapi import Query
from fastapi import Response

from app.api.responses import JSONResponse
from app.errors import Error
from app.errors import ErrorCode
from app.common_types import AkatsukiMode
from app.common_types import GameMode
from app.common_types import RelaxMode
from app.common_types import AkatsukiMode
from app.errors import Error
from app.errors import ErrorCode
from app.usecases import user_stats

router = APIRouter(tags=["(Public) User Stats API"])
Expand Down Expand Up @@ -35,7 +35,8 @@ async def get_user_stats(
akatsuki_mode = AkatsukiMode.from_game_mode_and_relax_mode(game_mode, relax_mode)

response = await user_stats.fetch_one_by_user_id_and_akatsuki_mode(
user_id, akatsuki_mode
user_id,
akatsuki_mode,
)
if isinstance(response, Error):
return JSONResponse(
Expand Down
10 changes: 6 additions & 4 deletions app/api/public/users.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging
from fastapi import APIRouter, Cookie

from fastapi import APIRouter
from fastapi import Cookie
from fastapi import Response
from pydantic import BaseModel

Expand Down Expand Up @@ -67,7 +69,7 @@ async def update_username(
return JSONResponse(
content=trusted_access_token.model_dump(),
status_code=map_error_code_to_http_status_code(
trusted_access_token.error_code
trusted_access_token.error_code,
),
)

Expand Down Expand Up @@ -100,7 +102,7 @@ async def update_password(
return JSONResponse(
content=trusted_access_token.model_dump(),
status_code=map_error_code_to_http_status_code(
trusted_access_token.error_code
trusted_access_token.error_code,
),
)

Expand Down Expand Up @@ -137,7 +139,7 @@ async def update_email_address(
return JSONResponse(
content=trusted_access_token.model_dump(),
status_code=map_error_code_to_http_status_code(
trusted_access_token.error_code
trusted_access_token.error_code,
),
)

Expand Down
3 changes: 2 additions & 1 deletion app/common_types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from enum import IntFlag
from enum import IntEnum
from enum import IntFlag


class UserPrivileges(IntFlag):
USER_PUBLIC = 1 << 0
Expand Down
20 changes: 16 additions & 4 deletions app/init_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,25 @@
from databases import Database
from fastapi import FastAPI
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi import Response
from starlette.middleware.base import RequestResponseEndpoint
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from redis.asyncio import Redis
from starlette.middleware.base import RequestResponseEndpoint

from app import logger
from app import settings
from app import state
from app.adapters import mysql
from app.api import api_router
from fastapi.responses import JSONResponse


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
logger.configure_logging()
await state.database.connect()
await state.redis.initialize() # type: ignore[unused-awaitable]

aws_session = aiobotocore.session.get_session()
s3_client = aws_session.create_client(
Expand All @@ -35,6 +38,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:

yield
await state.s3_client.__aexit__(None, None, None)
await state.redis.aclose()
await state.database.disconnect()


Expand All @@ -57,7 +61,8 @@ async def http_middleware(

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
logging.warning(
"Request validation failed",
Expand All @@ -70,6 +75,13 @@ async def validation_exception_handler(


def init_db(app: FastAPI) -> FastAPI:
state.redis = Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
username=settings.REDIS_USER,
password=settings.REDIS_PASS,
)
state.database = Database(
url=mysql.create_dsn(
driver="aiomysql",
Expand Down
75 changes: 75 additions & 0 deletions app/repositories/clans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from enum import IntEnum

from pydantic import BaseModel

import app.state


class ClanStatus(IntEnum):
CLOSED = 0
OPEN_FOR_ALL = 1
INVITE_ONLY = 2
REQUEST_TO_JOIN = 3


class Clan(BaseModel):
id: int
name: str
tag: str
description: str
icon: str
background: str
owner: int
invite: str
status: ClanStatus


READ_PARAMS = """\
id, name, tag, description, icon, background, owner, invite, status
"""


async def fetch_one_by_clan_id(clan_id: int, /) -> Clan | None:
query = f"""\
SELECT {READ_PARAMS}
FROM clans
WHERE id = :clan_id
"""
params = {"clan_id": clan_id}

clan = await app.state.database.fetch_one(query, params)
if clan is None:
return None

return Clan(
id=clan["id"],
name=clan["name"],
tag=clan["tag"],
description=clan["description"],
icon=clan["icon"],
background=clan["background"],
owner=clan["owner"],
invite=clan["invite"],
status=ClanStatus(clan["status"]),
)


async def update_owner(clan_id: int, new_owner: int) -> None:
query = """\
UPDATE clans
SET owner = :new_owner
WHERE id = :clan_id
"""
params = {"new_owner": new_owner, "clan_id": clan_id}

await app.state.database.execute(query, params)


async def delete_one_by_clan_id(clan_id: int, /) -> None:
query = """\
DELETE FROM clans
WHERE id = :clan_id
"""
params = {"clan_id": clan_id}

await app.state.database.execute(query, params)
Loading

0 comments on commit 3b0fe85

Please sign in to comment.