Skip to content
Merged
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
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ LOGIN_STATE_LIFETIME_SECONDS=300
SESSION_COOKIE_SECURE=true
# Use none only for secure cross-site showcase hosting; keep lax for same-site/local deployments.
SESSION_COOKIE_SAMESITE=lax

# Irreversible primary-store account erasure is disabled until the recovery
# window, backup-erasure schedule, privacy notice and translated UI are approved.
ACCOUNT_ERASURE_ENABLED=false
2 changes: 2 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ data and Identity Bridge completion work.
- GET /search-food?q=
- POST /log-food
- GET /logs
- GET /api/identity/export
- DELETE /api/identity/account (disabled by default pending human release approval)

## Local Run

Expand Down
131 changes: 131 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@
from .locales import resolve_locale
from .models import (
AuthSessionDB,
AuthorizationCodeDB,
BridgeAuthNonceDB,
CalorieAppUserDB,
ExternalIdentityDB,
FoodLogDB,
OriginLoginHandoffDB,
)
from .schemas import (
AccountErasureRequest,
AccountErasureResponse,
AccountDataExportResponse,
AccountExportAccount,
AccountExportAuthSession,
Expand Down Expand Up @@ -103,6 +106,11 @@
str(max(_BRIDGE_AUTH_MAX_AGE_SECONDS + _BRIDGE_AUTH_MAX_FUTURE_SECONDS, 330)),
)
)
_ACCOUNT_ERASURE_ENABLED = os.getenv("ACCOUNT_ERASURE_ENABLED", "false").lower() in {
"1",
"true",
"yes",
}
_IDENTITY_PROVIDER = "wordpress_xumm"

if not _WORDPRESS_BRIDGE_SECRET:
Expand Down Expand Up @@ -1074,6 +1082,129 @@ def identity_export(
)


@app.delete("/api/identity/account", response_model=AccountErasureResponse)
def identity_erase_account(
payload: AccountErasureRequest,
response: Response,
session: DbSession,
current_user: CurrentUser,
) -> AccountErasureResponse:
"""Erase one authenticated account from the primary store when explicitly enabled.

The endpoint is disabled by default. Enabling it remains a human release decision
after the recovery window, backup-erasure schedule, privacy notice and translated
confirmation UI have been approved.
"""
if not _ACCOUNT_ERASURE_ENABLED:
raise HTTPException(status_code=503, detail="Account erasure is not enabled")

if payload.confirm_user_id != current_user.id:
raise HTTPException(status_code=409, detail="Account confirmation did not match")

identities = session.exec(
select(ExternalIdentityDB).where(
ExternalIdentityDB.calorieapp_user_id == current_user.id
)
).all()
external_subjects = sorted({identity.external_subject for identity in identities})

# Authorization activity predates direct internal-user and provider ownership.
# Refuse erasure rather than assigning or deleting another user's legacy activity.
if external_subjects:
ambiguous_identity = session.exec(
select(ExternalIdentityDB).where(
ExternalIdentityDB.external_subject.in_(external_subjects),
ExternalIdentityDB.calorieapp_user_id != current_user.id,
)
).first()
if ambiguous_identity is not None:
raise HTTPException(
status_code=409,
detail="Account identity requires operator review before erasure",
)

legacy_authorization = session.exec(
select(AuthorizationCodeDB).where(
AuthorizationCodeDB.external_subject.in_(external_subjects)
)
).first()
if legacy_authorization is not None:
raise HTTPException(
status_code=409,
detail=(
"Account authorization history requires operator review "
"before erasure"
),
)

try:
session.exec(delete(FoodLogDB).where(FoodLogDB.owner_id == current_user.id))
session.exec(
delete(OriginLoginHandoffDB).where(
OriginLoginHandoffDB.calorieapp_user_id == current_user.id
)
)

# Break both outgoing and incoming self-references before removing every
# session for the account. An older session cookie can belong to another
# account while pointing at the replacement session created after login.
auth_sessions = session.exec(
select(AuthSessionDB).where(
AuthSessionDB.calorieapp_user_id == current_user.id
)
).all()
auth_session_ids = [
auth_session.id
for auth_session in auth_sessions
if auth_session.id is not None
]
inbound_references = (
session.exec(
select(AuthSessionDB).where(
AuthSessionDB.replaced_by_session_id.in_(auth_session_ids)
)
).all()
if auth_session_ids
else []
)
for inbound_reference in inbound_references:
inbound_reference.replaced_by_session_id = None
session.add(inbound_reference)
for auth_session in auth_sessions:
auth_session.replaced_by_session_id = None
session.add(auth_session)
session.flush()
session.exec(
delete(AuthSessionDB).where(
AuthSessionDB.calorieapp_user_id == current_user.id
)
)

session.exec(
delete(ExternalIdentityDB).where(
ExternalIdentityDB.calorieapp_user_id == current_user.id
)
)
session.exec(
delete(CalorieAppUserDB).where(CalorieAppUserDB.id == current_user.id)
)
session.commit()
except Exception:
session.rollback()
raise

response.delete_cookie(
key=SESSION_COOKIE_NAME,
path="/",
domain=None,
secure=_SESSION_COOKIE_SECURE,
Comment on lines +1196 to +1200
httponly=True,
samesite=_SESSION_COOKIE_SAMESITE,
)
logger.info("Authenticated account erased from primary store")
return AccountErasureResponse(status="erased")


@app.post("/api/identity/logout", response_model=LogoutResponse)
def identity_logout(
response: Response,
Expand Down
15 changes: 15 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,21 @@ def normalize_export_timestamp(cls, value: datetime) -> datetime:
return _ensure_utc(value)


class AccountErasureRequest(BaseModel):
"""Explicit confirmation bound to the authenticated internal account."""

model_config = ConfigDict(str_strip_whitespace=True)

confirm_user_id: str = Field(..., min_length=1, max_length=64)
acknowledgement: Literal["delete-my-calorieapp-account"]


class AccountErasureResponse(BaseModel):
"""Minimal response after irreversible primary-store erasure."""

status: Literal["erased"]


class LogoutResponse(BaseModel):
"""Response after logout."""

Expand Down
Loading