//! Profile sharing (Phase B). //! //! Implements the X25519 envelope: an owner wraps a profile DEK to a //! recipient's identity public key via ECDH, storing the opaque wrapped blob //! keyed by (profileId, recipientUserId). Recipients unwrap with their own //! identity private key. The server never sees the profile DEK. //! //! Also provides `authorize_profile_read` — the share-gate used by the data //! handlers (medications, appointments, health stats) to admit recipients. use axum::{ extract::{Extension, Path, Query, State}, http::StatusCode, Json, }; use mongodb::bson::{oid::ObjectId, DateTime}; use serde::{Deserialize, Serialize}; use crate::{ auth::jwt::Claims, config::AppState, models::profile::ProfileRepository, models::profile_share::{ProfileShare, ProfileShareRepository}, }; fn profile_repo(state: &AppState) -> ProfileRepository { ProfileRepository::new(state.db.get_database().collection("profiles")) } fn share_repo(state: &AppState) -> ProfileShareRepository { ProfileShareRepository::new(state.db.get_database().collection("profile_shares")) } /// The shared profile as seen by the recipient — the profile's metadata /// (display name blob, kind, relationship) plus the per-share ECDH-wrapped DEK /// the recipient unwraps with their identity private key. The server returns /// the wrapped DEK from the share record verbatim and cannot read it. #[derive(Debug, Serialize)] pub struct SharedProfileResponse { pub profile_id: String, pub owner_account_id: String, pub name_data: String, pub name_iv: String, pub kind: String, pub relationship: String, /// Per-share ephemeral X25519 public key (base64 raw). The recipient /// combines it with their identity private key to derive the wrap key. pub ephemeral_public_key: String, /// Profile DEK wrapped under the ECDH-derived key (opaque). pub wrapped_profile_dek: String, pub wrapped_profile_dek_iv: String, pub permissions: Vec, pub created_at: DateTime, } /// A share as listed by the owner (no wrapped DEK on this view — it's only /// useful to the recipient; the owner already has the profile DEK directly). #[derive(Debug, Serialize)] pub struct ShareListingResponse { pub profile_id: String, pub recipient_user_id: String, pub recipient_email: String, pub permissions: Vec, pub created_at: DateTime, pub active: bool, } // --------------------------------------------------------------------------- // Share-gate: resolve the owner userId for a profile the caller may read. // Used by the data handlers to admit recipients. // --------------------------------------------------------------------------- /// If the caller (claims.sub) owns the profile, returns Ok(owner_user_id). /// Otherwise checks for an active share; if present, returns Ok(owner_user_id) /// (the profile's owner — recipients read "as the owner" for that profile). /// Returns Err(404 response) if the caller has no read access. pub async fn authorize_profile_read( state: &AppState, claims: &Claims, profile_id: &str, ) -> Result)> { let profiles = profile_repo(state); // Owner path. if let Ok(Some(owned)) = profiles .find_by_profile_id_owned(profile_id, &claims.sub) .await { return Ok(owned.owner_account_id); } // Recipient path: an active (unexpired, not-revoked) share for this pair. let shares = share_repo(state); match shares.find_active(profile_id, &claims.sub).await { Ok(Some(share)) => Ok(share.owner_user_id), Ok(None) => Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "profile not found" })), )), Err(e) => { tracing::error!("Share lookup failed for {}: {}", profile_id, e); Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )) } } } // --------------------------------------------------------------------------- // POST /api/profiles/:id/shares — owner shares a profile to a recipient. // --------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct CreateShareRequest { /// The recipient, identified by email. pub recipient_email: String, /// Ephemeral X25519 public key (base64 raw) generated by the owner's /// client for this share. pub ephemeral_public_key: String, /// Profile DEK wrapped (client-side, via ECDH) to the recipient. Opaque. pub wrapped_profile_dek: String, pub wrapped_profile_dek_iv: String, #[serde(default = "default_read_permissions")] pub permissions: Vec, /// Optional RFC-3339 expiry; null/absent = never expires. pub expires_at: Option, } fn default_read_permissions() -> Vec { vec!["read".to_string()] } pub async fn create_share( State(state): State, Extension(claims): Extension, Path(profile_id): Path, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { // Verify the caller owns the profile. let profiles = profile_repo(&state); let owner = match profiles .find_by_profile_id_owned(&profile_id, &claims.sub) .await { Ok(Some(p)) => p.owner_account_id, Ok(None) => { return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "profile not found" })), )); } Err(e) => { tracing::error!("Profile lookup in create_share failed: {}", e); return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )); } }; // Resolve recipient by email. let recipient = match state.db.find_user_by_email(&req.recipient_email).await { Ok(Some(u)) => u, Ok(None) => { return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "recipient not found" })), )); } Err(e) => { tracing::error!("Recipient lookup failed: {}", e); return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )); } }; let recipient_id = match recipient.id { Some(id) => id.to_string(), None => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "invalid recipient state" })), )); } }; // Can't share to an account without an identity public key (pre-A1). if recipient .identity_public_key .as_deref() .unwrap_or("") .is_empty() { return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "recipient has no identity public key (account predates the keypair feature)" })), )); } // Don't share to yourself. if recipient_id == claims.sub { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "cannot share a profile with yourself" })), )); } let expires_at = match req.expires_at.as_deref() { Some(s) => match DateTime::parse_rfc3339_str(s) { Ok(dt) => Some(dt), Err(_) => { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "invalid expires_at (expected RFC 3339)" })), )); } }, None => None, }; let now = DateTime::now(); let share = ProfileShare { id: None, profile_id: profile_id.clone(), owner_user_id: owner.clone(), recipient_user_id: recipient_id.clone(), ephemeral_public_key: req.ephemeral_public_key, wrapped_profile_dek: req.wrapped_profile_dek, wrapped_profile_dek_iv: req.wrapped_profile_dek_iv, permissions: req.permissions.clone(), expires_at, created_at: now, active: true, }; let shares = share_repo(&state); if let Err(e) = shares.upsert(&share).await { tracing::error!("Share create failed: {}", e); return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )); } Ok(( StatusCode::CREATED, Json(ShareListingResponse { profile_id, recipient_user_id: recipient_id, recipient_email: req.recipient_email, permissions: req.permissions, created_at: now, active: true, }), )) } // --------------------------------------------------------------------------- // GET /api/profiles/:id/shares — owner lists who they've shared a profile with. // --------------------------------------------------------------------------- pub async fn list_shares( State(state): State, Extension(claims): Extension, Path(profile_id): Path, ) -> Result>, (StatusCode, Json)> { // Owner-only: confirm ownership. let profiles = profile_repo(&state); match profiles .find_by_profile_id_owned(&profile_id, &claims.sub) .await { Ok(Some(_)) => {} Ok(None) => { return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "profile not found" })), )); } Err(e) => { tracing::error!("Profile lookup in list_shares failed: {}", e); return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )); } } let shares = share_repo(&state); let rows = match shares.find_for_profile(&profile_id).await { Ok(rows) => rows, Err(e) => { tracing::error!("Share list failed: {}", e); return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )); } }; // Resolve each recipient's email for display. let mut out = Vec::with_capacity(rows.len()); for s in rows { let email = match ObjectId::parse_str(&s.recipient_user_id) { Ok(oid) => match state.db.find_user_by_id(&oid).await { Ok(Some(u)) => u.email, _ => String::new(), }, Err(_) => String::new(), }; out.push(ShareListingResponse { profile_id: s.profile_id, recipient_user_id: s.recipient_user_id, recipient_email: email, permissions: s.permissions, created_at: s.created_at, active: s.active, }); } Ok(Json(out)) } // --------------------------------------------------------------------------- // DELETE /api/profiles/:id/shares/:recipient — soft revoke (hard delete). // --------------------------------------------------------------------------- pub async fn delete_share( State(state): State, Extension(claims): Extension, Path((profile_id, recipient_user_id)): Path<(String, String)>, ) -> Result)> { // Owner-only: confirm ownership. let profiles = profile_repo(&state); match profiles .find_by_profile_id_owned(&profile_id, &claims.sub) .await { Ok(Some(_)) => {} Ok(None) => { return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "profile not found" })), )); } Err(e) => { tracing::error!("Profile lookup in delete_share failed: {}", e); return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )); } } let shares = share_repo(&state); match shares.delete(&profile_id, &recipient_user_id).await { Ok(true) => Ok(StatusCode::NO_CONTENT), Ok(false) => Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "share not found" })), )), Err(e) => { tracing::error!("Share delete failed: {}", e); Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )) } } } // --------------------------------------------------------------------------- // GET /api/profiles/shared-with-me — recipient lists profiles shared with them. // --------------------------------------------------------------------------- pub async fn list_shared_with_me( State(state): State, Extension(claims): Extension, ) -> Result>, (StatusCode, Json)> { let shares = share_repo(&state); let my_shares = match shares.find_for_recipient(&claims.sub).await { Ok(rows) => rows, Err(e) => { tracing::error!("shared-with-me list failed: {}", e); return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )); } }; // Join each share with its profile's metadata. Only return shares for // profiles that still exist and are still active/unexpired (find_active // semantics, re-checked here to avoid listing a just-expired share). let profiles = profile_repo(&state); let mut out = Vec::with_capacity(my_shares.len()); for s in my_shares { // Skip expired/inactive — find_for_recipient returns all rows; filter // to currently-usable ones for the recipient's view. if !s.active { continue; } if let Some(exp) = s.expires_at { if exp <= DateTime::now() { continue; } } let profile = match profiles.find_by_profile_id(&s.profile_id).await { Ok(Some(p)) => p, _ => continue, // profile deleted; skip silently }; out.push(SharedProfileResponse { profile_id: profile.profile_id, owner_account_id: profile.owner_account_id, name_data: profile.name, name_iv: profile.name_iv, kind: profile.kind, relationship: profile.relationship, ephemeral_public_key: s.ephemeral_public_key, wrapped_profile_dek: s.wrapped_profile_dek, wrapped_profile_dek_iv: s.wrapped_profile_dek_iv, permissions: s.permissions, created_at: s.created_at, }); } Ok(Json(out)) } // --------------------------------------------------------------------------- // GET /api/profiles/:id — extend the existing profile GET to admit recipients. // This stands in for "the recipient can fetch one shared profile's metadata". // Implemented here so the share-aware logic lives with the share code. // --------------------------------------------------------------------------- /// Fetch a single profile's metadata, admitting both owner and share-recipient. /// Returns the owner's-view `ProfileResponse` shape (the wrapped profile DEK /// there is account-wrapped — recipients ignore it and use the share's /// ECDH-wrapped DEK from /profiles/shared-with-me instead). pub async fn get_profile_shared_aware( State(state): State, Extension(claims): Extension, Path(profile_id): Path, ) -> Result, (StatusCode, Json)> { // authorize_profile_read admits owner or active-share recipient. let _owner = authorize_profile_read(&state, &claims, &profile_id).await?; let profiles = profile_repo(&state); match profiles.find_by_profile_id(&profile_id).await { Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))), Ok(None) => Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "profile not found" })), )), Err(e) => { tracing::error!("Profile fetch failed: {}", e); Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), )) } } } // Re-export ProfileResponse from the profile handlers for the shared-aware GET. pub use crate::handlers::profile::ProfileResponse; // --------------------------------------------------------------------------- // GET /api/users/public-key — look up a recipient's identity public key. // Public (no auth) — public keys are not secret. // --------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct PublicKeyQuery { pub email: String, } #[derive(Debug, Serialize)] pub struct PublicKeyResponse { pub user_id: String, pub identity_public_key: String, } pub async fn get_public_key( State(state): State, Query(q): Query, ) -> Result, (StatusCode, Json)> { let user = match state.db.find_user_by_email(&q.email).await { Ok(Some(u)) => u, Ok(None) | Err(_) => { // Don't leak whether the email exists. return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "no public key for this address" })), )); } }; let user_id = user .id .ok_or(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "invalid user state" })), ))? .to_string(); let pk = user.identity_public_key.unwrap_or_default(); if pk.is_empty() { return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "no public key for this address" })), )); } Ok(Json(PublicKeyResponse { user_id, identity_public_key: pk, })) }