diff --git a/backend/app/main.py b/backend/app/main.py index e2e31cc..ed00b23 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,8 +21,20 @@ from .database import database_readiness, get_session, init_db from .locales import resolve_locale -from .models import AuthSessionDB, BridgeAuthNonceDB, CalorieAppUserDB, FoodLogDB +from .models import ( + AuthSessionDB, + BridgeAuthNonceDB, + CalorieAppUserDB, + ExternalIdentityDB, + FoodLogDB, + OriginLoginHandoffDB, +) from .schemas import ( + AccountDataExportResponse, + AccountExportAccount, + AccountExportAuthSession, + AccountExportExternalIdentity, + AccountExportLoginHandoff, CurrentUserResponse, FoodLog, FoodLogCreate, @@ -957,6 +969,111 @@ def identity_me( ) +@app.get("/api/identity/export", response_model=AccountDataExportResponse) +def identity_export( + response: Response, + session: DbSession, + current_user: CurrentUser, +) -> AccountDataExportResponse: + """Return a versioned private export of linked data without authentication secrets.""" + identities = session.exec( + select(ExternalIdentityDB) + .where(ExternalIdentityDB.calorieapp_user_id == current_user.id) + .order_by(ExternalIdentityDB.created_at, ExternalIdentityDB.id) + ).all() + external_subjects = [identity.external_subject for identity in identities] + + # Authorization activity predates a direct internal-user foreign key. Refuse + # export rather than returning another user's activity when a legacy subject is + # ambiguously linked across providers or accounts. + 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 export", + ) + + food_logs = session.exec( + select(FoodLogDB) + .where(FoodLogDB.owner_id == current_user.id) + .order_by(FoodLogDB.created_at, FoodLogDB.id) + ).all() + auth_sessions = session.exec( + select(AuthSessionDB) + .where(AuthSessionDB.calorieapp_user_id == current_user.id) + .order_by(AuthSessionDB.created_at, AuthSessionDB.id) + ).all() + handoffs = session.exec( + select(OriginLoginHandoffDB) + .where(OriginLoginHandoffDB.calorieapp_user_id == current_user.id) + .order_by(OriginLoginHandoffDB.created_at, OriginLoginHandoffDB.id) + ).all() + + response.headers["Content-Disposition"] = ( + 'attachment; filename="calorieapp-account-data-v1.json"' + ) + + return AccountDataExportResponse( + export_version="calorieapp-account-data-v1", + exported_at=datetime.now(UTC), + account=AccountExportAccount( + user_id=current_user.id, + status=current_user.status, + created_at=current_user.created_at, + updated_at=current_user.updated_at, + ), + external_identities=[ + AccountExportExternalIdentity( + provider=identity.provider, + external_subject=identity.external_subject, + xrpl_address=identity.xrpl_address, + created_at=identity.created_at, + last_verified_at=identity.last_verified_at, + ) + for identity in identities + ], + food_logs=[FoodLog.model_validate(entry.model_dump()) for entry in food_logs], + authentication_sessions=[ + AccountExportAuthSession( + created_at=auth_session.created_at, + last_seen_at=auth_session.last_seen_at, + expires_at=auth_session.expires_at, + revoked_at=auth_session.revoked_at, + ) + for auth_session in auth_sessions + ], + # Legacy authorization rows have only a subject, not direct internal-user + # and provider ownership. Preserve the v1 field while withholding rows + # until a migration can prove ownership without inference. + authorization_events=[], + login_handoffs=[ + AccountExportLoginHandoff( + status=handoff.status, + created_at=handoff.created_at, + expires_at=handoff.expires_at, + completed_at=handoff.completed_at, + claimed_at=handoff.claimed_at, + failure_code=handoff.failure_code, + ) + for handoff in handoffs + ], + excluded_security_fields=[ + "authorization_code_hash", + "authorization_state", + "login_session_id", + "session_token_hash", + "handoff_state_hash", + "handoff_token_hash", + ], + ) + + @app.post("/api/identity/logout", response_model=LogoutResponse) def identity_logout( response: Response, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a51ef66..63a9a33 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -194,6 +194,120 @@ def serialize_created_at_as_utc(cls, value: datetime) -> datetime: return _ensure_utc(value) +class AccountExportAccount(BaseModel): + """Portable non-secret account fields owned by the authenticated user.""" + + user_id: str + status: str + created_at: datetime + updated_at: datetime + + @field_validator("created_at", "updated_at", mode="after") + @classmethod + def normalize_account_timestamps(cls, value: datetime) -> datetime: + return _ensure_utc(value) + + +class AccountExportExternalIdentity(BaseModel): + """External identity link included in the user's private export.""" + + provider: str + external_subject: str + xrpl_address: Optional[str] + created_at: datetime + last_verified_at: datetime + + @field_validator("created_at", "last_verified_at", mode="after") + @classmethod + def normalize_identity_timestamps(cls, value: datetime) -> datetime: + return _ensure_utc(value) + + +class AccountExportAuthSession(BaseModel): + """Session activity metadata without token hashes or internal identifiers.""" + + created_at: datetime + last_seen_at: datetime + expires_at: datetime + revoked_at: Optional[datetime] + + @field_validator( + "created_at", + "last_seen_at", + "expires_at", + "revoked_at", + mode="after", + ) + @classmethod + def normalize_session_timestamps( + cls, + value: Optional[datetime], + ) -> Optional[datetime]: + return _ensure_utc(value) if value is not None else None + + +class AccountExportAuthorizationEvent(BaseModel): + """Reserved v1 shape for authorization activity with proven ownership.""" + + external_subject: str + created_at: datetime + expires_at: datetime + used_at: Optional[datetime] + used_by_ip: Optional[str] + + @field_validator("created_at", "expires_at", "used_at", mode="after") + @classmethod + def normalize_authorization_timestamps( + cls, + value: Optional[datetime], + ) -> Optional[datetime]: + return _ensure_utc(value) if value is not None else None + + +class AccountExportLoginHandoff(BaseModel): + """Browser handoff activity without state or handoff-token hashes.""" + + status: str + created_at: datetime + expires_at: datetime + completed_at: Optional[datetime] + claimed_at: Optional[datetime] + failure_code: Optional[str] + + @field_validator( + "created_at", + "expires_at", + "completed_at", + "claimed_at", + mode="after", + ) + @classmethod + def normalize_handoff_timestamps( + cls, + value: Optional[datetime], + ) -> Optional[datetime]: + return _ensure_utc(value) if value is not None else None + + +class AccountDataExportResponse(BaseModel): + """Versioned authenticated CalorieApp account-data export.""" + + export_version: Literal["calorieapp-account-data-v1"] + exported_at: datetime + account: AccountExportAccount + external_identities: list[AccountExportExternalIdentity] + food_logs: list[FoodLog] + authentication_sessions: list[AccountExportAuthSession] + authorization_events: list[AccountExportAuthorizationEvent] + login_handoffs: list[AccountExportLoginHandoff] + excluded_security_fields: list[str] + + @field_validator("exported_at", mode="after") + @classmethod + def normalize_export_timestamp(cls, value: datetime) -> datetime: + return _ensure_utc(value) + + class LogoutResponse(BaseModel): """Response after logout.""" diff --git a/backend/tests/test_account_data_export.py b/backend/tests/test_account_data_export.py new file mode 100644 index 0000000..02064a7 --- /dev/null +++ b/backend/tests/test_account_data_export.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from fastapi.testclient import TestClient +from sqlmodel import Session + +import app.database as db_module +from app.models import ( + AuthorizationCodeDB, + CalorieAppUserDB, + ExternalIdentityDB, + FoodLogDB, + OriginLoginHandoffDB, +) + + +def test_account_data_export_requires_authentication(client: TestClient) -> None: + response = client.get("/api/identity/export") + + assert response.status_code == 401 + assert response.headers["cache-control"] == "no-store" + + +def test_account_data_export_is_complete_scoped_versioned_and_secret_free( + authenticated_client: TestClient, +) -> None: + me = authenticated_client.get("/api/identity/me") + assert me.status_code == 200 + user_id = me.json()["user_id"] + + now = datetime.now(UTC) + own_subject = "wp:calorietoken.net:export-owner" + other_subject = "wp:calorietoken.net:other-user" + own_handoff_state_hash = "b" * 64 + own_handoff_token_hash = "c" * 64 + + with Session(db_module.engine) as session: + other_user = CalorieAppUserDB(status="active") + session.add(other_user) + session.commit() + session.refresh(other_user) + + session.add_all( + [ + ExternalIdentityDB( + calorieapp_user_id=user_id, + provider="wordpress_xumm", + external_subject=own_subject, + xrpl_address="rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + created_at=now - timedelta(days=2), + last_verified_at=now - timedelta(days=1), + ), + ExternalIdentityDB( + calorieapp_user_id=other_user.id, + provider="wordpress_xumm", + external_subject=other_subject, + created_at=now - timedelta(days=2), + last_verified_at=now - timedelta(days=1), + ), + OriginLoginHandoffDB( + state_hash=own_handoff_state_hash, + handoff_token_hash=own_handoff_token_hash, + status="claimed", + calorieapp_user_id=user_id, + created_at=now - timedelta(minutes=3), + expires_at=now + timedelta(minutes=2), + completed_at=now - timedelta(minutes=2), + claimed_at=now - timedelta(minutes=1), + ), + OriginLoginHandoffDB( + state_hash="e" * 64, + handoff_token_hash="f" * 64, + status="completed", + calorieapp_user_id=other_user.id, + created_at=now - timedelta(minutes=3), + expires_at=now + timedelta(minutes=2), + ), + FoodLogDB( + product_name="Other user's private food", + calories=999, + owner_id=other_user.id, + ), + ] + ) + session.commit() + + create_log = authenticated_client.post( + "/log-food", + json={"product_name": "Exported apple", "calories": 52}, + ) + assert create_log.status_code == 200 + + response = authenticated_client.get("/api/identity/export") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.headers["pragma"] == "no-cache" + assert response.headers["content-disposition"] == ( + 'attachment; filename="calorieapp-account-data-v1.json"' + ) + + data = response.json() + assert data["export_version"] == "calorieapp-account-data-v1" + assert data["account"]["user_id"] == user_id + assert data["account"]["status"] == "active" + assert {identity["external_subject"] for identity in data["external_identities"]} == { + own_subject + } + assert [food_log["product_name"] for food_log in data["food_logs"]] == [ + "Exported apple" + ] + assert len(data["authentication_sessions"]) == 1 + assert data["authorization_events"] == [] + assert len(data["login_handoffs"]) == 1 + assert data["login_handoffs"][0]["status"] == "claimed" + + exported_text = response.text + for secret in ( + own_handoff_state_hash, + own_handoff_token_hash, + "other-user", + "Other user's private food", + ): + assert secret not in exported_text + + assert "session_token_hash" not in data["authentication_sessions"][0] + assert "authorization_code_hash" in data["excluded_security_fields"] + assert "handoff_token_hash" in data["excluded_security_fields"] + + +def test_account_data_export_withholds_unowned_legacy_authorization_events( + authenticated_client: TestClient, +) -> None: + user_id = authenticated_client.get("/api/identity/me").json()["user_id"] + now = datetime.now(UTC) + subject = "wp:calorietoken.net:legacy-authorization-subject" + code_hash = "a" * 64 + state = "unowned-private-state" + login_session_id = "unowned-private-login-session" + used_by_ip = "203.0.113.10" + + with Session(db_module.engine) as session: + session.add_all( + [ + ExternalIdentityDB( + calorieapp_user_id=user_id, + provider="wordpress_xumm", + external_subject=subject, + ), + AuthorizationCodeDB( + code_hash=code_hash, + external_subject=subject, + state=state, + login_session_id=login_session_id, + created_at=now - timedelta(minutes=3), + expires_at=now + timedelta(minutes=2), + used_at=now - timedelta(minutes=1), + used_by_ip=used_by_ip, + ), + ] + ) + session.commit() + + response = authenticated_client.get("/api/identity/export") + + assert response.status_code == 200 + assert response.json()["authorization_events"] == [] + for private_value in (code_hash, state, login_session_id, used_by_ip): + assert private_value not in response.text + + +def test_account_data_export_fails_closed_for_ambiguous_external_subject( + authenticated_client: TestClient, +) -> None: + user_id = authenticated_client.get("/api/identity/me").json()["user_id"] + now = datetime.now(UTC) + shared_subject = "shared-subject-across-providers" + other_users_ip = "198.51.100.77" + + with Session(db_module.engine) as session: + other_user = CalorieAppUserDB(status="active") + session.add(other_user) + session.commit() + session.refresh(other_user) + + session.add_all( + [ + ExternalIdentityDB( + calorieapp_user_id=user_id, + provider="wordpress_xumm", + external_subject=shared_subject, + ), + ExternalIdentityDB( + calorieapp_user_id=other_user.id, + provider="legacy_partner", + external_subject=shared_subject, + ), + AuthorizationCodeDB( + code_hash="f" * 64, + external_subject=shared_subject, + state="other-users-private-state", + login_session_id="other-users-private-login", + created_at=now - timedelta(minutes=3), + expires_at=now + timedelta(minutes=2), + used_at=now - timedelta(minutes=1), + used_by_ip=other_users_ip, + ), + ] + ) + session.commit() + + response = authenticated_client.get("/api/identity/export") + + assert response.status_code == 409 + assert response.json() == { + "detail": "Account identity requires operator review before export" + } + assert "content-disposition" not in response.headers + assert "authorization_events" not in response.text + assert other_users_ip not in response.text diff --git a/backend/tests/test_data_safety_contract.py b/backend/tests/test_data_safety_contract.py index 75c8d61..e353893 100644 --- a/backend/tests/test_data_safety_contract.py +++ b/backend/tests/test_data_safety_contract.py @@ -92,6 +92,32 @@ def test_data_classes_cover_current_and_planned_personal_flows() -> None: ) +def test_account_export_is_private_versioned_and_secret_free() -> None: + export = _load_json("data-safety.json")["account_data_export"] + + assert export["status"] == "v2-backend-implemented-ui-and-notice-pending" + assert export["format"] == "versioned-json" + assert export["format_version"] == "calorieapp-account-data-v1" + assert export["authenticated_user_only"] is True + assert export["cross_user_records_allowed"] is False + assert export["private_http_caching_allowed"] is False + assert export["external_delivery_or_publication_performed"] is False + assert export["security_token_hashes_codes_and_login_state_included"] is False + assert export[ + "identity_food_history_and_directly_owned_authentication_activity_included" + ] is True + assert export[ + "legacy_authorization_events_without_direct_ownership_included" + ] is False + assert export["authorization_events_field_reserved_as_empty_list"] is True + assert export[ + "direct_ownership_migration_required_before_authorization_event_inclusion" + ] is True + assert export["eleven_language_identity_bridge_ui_required"] is True + assert export["privacy_notice_alignment_required"] is True + assert export["account_erasure_or_retention_policy_changed"] is False + + def test_xrpl_linking_is_optional_off_chain_and_privacy_preserving() -> None: contract = _load_json("data-safety.json") linking = contract["xrpl_transaction_linking"] @@ -416,6 +442,10 @@ def test_all_required_durable_data_release_gates_are_explicit_and_blocking() -> assert gates["zero_additional_cost_capacity_and_exit_plan"]["status"] == "partial" assert gates["ecosystem_operator_succession_and_handover"]["status"] == "partial" assert gates["restart_persistence"]["status"] == "partial" + assert gates["user_data_export"]["status"] == "partial" + assert "backend/tests/test_account_data_export.py" in gates["user_data_export"][ + "evidence" + ] assert gates["retention_policy"]["status"] == "decision_required" assert matrix["release_state"] == "blocked" diff --git a/contracts/data-safety/v1/data-safety.json b/contracts/data-safety/v1/data-safety.json index a5b3393..dc7f694 100644 --- a/contracts/data-safety/v1/data-safety.json +++ b/contracts/data-safety/v1/data-safety.json @@ -104,6 +104,23 @@ "account erasure confirmation and recovery window" ] }, + "account_data_export": { + "status": "v2-backend-implemented-ui-and-notice-pending", + "format": "versioned-json", + "format_version": "calorieapp-account-data-v1", + "authenticated_user_only": true, + "cross_user_records_allowed": false, + "private_http_caching_allowed": false, + "external_delivery_or_publication_performed": false, + "security_token_hashes_codes_and_login_state_included": false, + "identity_food_history_and_directly_owned_authentication_activity_included": true, + "legacy_authorization_events_without_direct_ownership_included": false, + "authorization_events_field_reserved_as_empty_list": true, + "direct_ownership_migration_required_before_authorization_event_inclusion": true, + "eleven_language_identity_bridge_ui_required": true, + "privacy_notice_alignment_required": true, + "account_erasure_or_retention_policy_changed": false + }, "backup_and_recovery": { "encrypted_at_rest_and_in_transit_required": true, "restricted_operator_access_required": true, diff --git a/contracts/data-safety/v1/release-test-matrix.json b/contracts/data-safety/v1/release-test-matrix.json index 4f0fdc7..98652ee 100644 --- a/contracts/data-safety/v1/release-test-matrix.json +++ b/contracts/data-safety/v1/release-test-matrix.json @@ -108,9 +108,14 @@ }, { "id": "user_data_export", - "status": "not_started", + "status": "partial", "release_blocking": true, - "evidence": [] + "evidence": [ + "backend/app/main.py", + "backend/app/schemas.py", + "backend/tests/test_account_data_export.py", + "docs/ACCOUNT_DATA_EXPORT.md" + ] }, { "id": "user_erasure", diff --git a/docs/ACCOUNT_DATA_EXPORT.md b/docs/ACCOUNT_DATA_EXPORT.md new file mode 100644 index 0000000..56c778b --- /dev/null +++ b/docs/ACCOUNT_DATA_EXPORT.md @@ -0,0 +1,49 @@ +# Authenticated account-data export + +Status: V2 backend implemented and tested; user-interface placement and privacy +notice alignment remain release-blocking. + +## Endpoint + +`GET /api/identity/export` requires the normal opaque CalorieApp session cookie. +The response is a downloadable JSON document with the stable version identifier +`calorieapp-account-data-v1`. Identity responses and the export are marked +`Cache-Control: no-store` and `Pragma: no-cache`. + +The export contains only records belonging to the authenticated internal user: + +- account identifier, status and timestamps; +- linked external identities and optional XRPL address; +- every owned private food-log snapshot; +- session activity timestamps; +- completed or failed browser-login handoff activity. + +Cross-user records are excluded. Legacy food logs whose owner is unknown remain +quarantined and are not silently claimed by any account. + +The version-one `authorization_events` field is retained as an empty list for +format compatibility. Legacy authorization rows contain an external subject but +no direct internal-user or provider ownership, so their timestamps and stored +request IP cannot be safely attributed and are withheld. They may be included +only after a migration records direct ownership without inferring it from the +subject. + +## Deliberately excluded secrets + +The export never returns authorization-code hashes, login state, internal login +session identifiers, opaque session-token hashes, or browser-handoff state/token +hashes. Their presence would weaken authentication without improving data +portability. The response names these excluded security fields so the boundary +is transparent. + +## Still required before public onboarding + +- add the export control and explanation to the eleven-language Identity Bridge + account/profile experience; +- align the privacy notice with the exact exported data classes; +- complete PostgreSQL and restore-path verification; +- obtain a human decision on account erasure, recovery window, inactive-account + retention and backup-erasure timing. + +This change does not enable account erasure, choose a retention period, send an +export to a third party or publish private history.