diff --git a/backend/src/app.rs b/backend/src/app.rs index 27c39b0..00e7667 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -35,7 +35,10 @@ pub fn build_app(state: AppState) -> Router { "/api/auth/recover-password", post(handlers::recover_password), ) - .route("/api/auth/recovery-info", get(handlers::recovery_info)); + .route("/api/auth/recovery-info", get(handlers::recovery_info)) + // Recipient identity public key lookup (Phase B). Public keys are not + // secret, so this is unauthenticated — mirrors recovery-info. + .route("/api/users/public-key", get(handlers::get_public_key)); // Build protected routes (auth required) let protected_routes = Router::new() @@ -47,19 +50,22 @@ pub fn build_app(state: AppState) -> Router { // User settings .route("/api/users/me/settings", get(handlers::get_settings)) .route("/api/users/me/settings", put(handlers::update_settings)) - // Share management - .route("/api/shares", post(handlers::create_share)) - .route("/api/shares", get(handlers::list_shares)) - .route("/api/shares/:id", put(handlers::update_share)) - .route("/api/shares/:id", delete(handlers::delete_share)) - // Permission checking - .route("/api/permissions/check", post(handlers::check_permission)) - // Profile management (Phase A2: multi-profile + per-profile DEKs) + // Profile management (Phase A2 multi-profile + Phase B sharing). + // GET /api/profiles/:id is share-aware (admits recipients); the other + // profile ops remain owner-only. .route("/api/profiles", get(handlers::list_profiles)) .route("/api/profiles", post(handlers::create_profile)) - .route("/api/profiles/:id", get(handlers::get_profile)) + .route("/api/profiles/shared-with-me", get(handlers::list_shared_with_me)) + .route("/api/profiles/:id", get(handlers::get_profile_shared_aware)) .route("/api/profiles/:id", put(handlers::update_profile)) .route("/api/profiles/:id", delete(handlers::delete_profile)) + // Profile sharing (Phase B): owner manages shares; recipients read via + // the gate inside the data handlers. + .route( + "/api/profiles/:id/shares", + get(handlers::list_shares).post(handlers::create_share), + ) + .route("/api/profiles/:id/shares/:recipient", delete(handlers::delete_share)) // Session management (Phase 2.6) .route("/api/sessions", get(handlers::get_sessions)) .route("/api/sessions/:id", delete(handlers::revoke_session)) diff --git a/backend/src/db/init.rs b/backend/src/db/init.rs index 8193095..6cdd71f 100644 --- a/backend/src/db/init.rs +++ b/backend/src/db/init.rs @@ -98,15 +98,30 @@ impl DatabaseInitializer { println!("✓ Created appointments collection"); } - // Create shares collection and index + // Create profile_shares collection and indexes (Phase B — replaces the + // legacy shares collection). Lookups are by (profileId, recipientUserId) + // and by recipientUserId alone (for /profiles/shared-with-me). { - let collection: Collection = self.db.collection("shares"); + let collection: Collection = + self.db.collection("profile_shares"); - let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build(); + let pair_index = IndexModel::builder() + .keys(doc! { "profileId": 1, "recipientUserId": 1 }) + .build(); + match collection.create_index(pair_index, None).await { + Ok(_) => println!("✓ Created index on profile_shares (profileId, recipientUserId)"), + Err(e) => println!("Warning: Failed to create profile_shares pair index: {}", e), + } - match collection.create_index(index, None).await { - Ok(_) => println!("✓ Created index on shares.familyId"), - Err(e) => println!("Warning: Failed to create index on shares.familyId: {}", e), + let recipient_index = IndexModel::builder() + .keys(doc! { "recipientUserId": 1 }) + .build(); + match collection.create_index(recipient_index, None).await { + Ok(_) => println!("✓ Created index on profile_shares.recipientUserId"), + Err(e) => println!( + "Warning: Failed to create profile_shares recipient index: {}", + e + ), } } diff --git a/backend/src/db/mongodb_impl.rs b/backend/src/db/mongodb_impl.rs index e1410ae..64952df 100644 --- a/backend/src/db/mongodb_impl.rs +++ b/backend/src/db/mongodb_impl.rs @@ -5,8 +5,6 @@ use std::time::Duration; use crate::models::{ medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest}, - permission::Permission, - share::{Share, ShareRepository}, user::{User, UserRepository}, }; @@ -14,7 +12,6 @@ use crate::models::{ pub struct MongoDb { database: Database, pub users: Collection, - pub shares: Collection, pub medications: Collection, pub medication_doses: Collection, } @@ -62,7 +59,6 @@ impl MongoDb { return Ok(Self { users: database.collection("users"), - shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -97,7 +93,6 @@ impl MongoDb { return Ok(Self { users: database.collection("users"), - shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -113,7 +108,6 @@ impl MongoDb { Ok(Self { users: database.collection("users"), - shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -177,100 +171,6 @@ impl MongoDb { Ok(()) } - // ===== Share Methods ===== - - pub async fn create_share(&self, share: &Share) -> Result> { - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.create(share).await?) - } - - pub async fn get_share(&self, id: &str) -> Result> { - let object_id = ObjectId::parse_str(id)?; - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.find_by_id(&object_id).await?) - } - - pub async fn list_shares_for_user(&self, user_id: &str) -> Result> { - let object_id = ObjectId::parse_str(user_id)?; - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.find_by_target(&object_id).await?) - } - - pub async fn update_share(&self, share: &Share) -> Result<()> { - let repo = ShareRepository::new(self.shares.clone()); - repo.update(share).await?; - Ok(()) - } - - pub async fn delete_share(&self, id: &str) -> Result<()> { - let object_id = ObjectId::parse_str(id)?; - let repo = ShareRepository::new(self.shares.clone()); - repo.delete(&object_id).await?; - Ok(()) - } - - // ===== Permission Methods ===== - - pub async fn check_user_permission( - &self, - user_id: &str, - resource_type: &str, - resource_id: &str, - permission: &str, - ) -> Result { - let user_oid = ObjectId::parse_str(user_id)?; - let resource_oid = ObjectId::parse_str(resource_id)?; - - let repo = ShareRepository::new(self.shares.clone()); - let shares = repo.find_by_target(&user_oid).await?; - - for share in shares { - if share.resource_type == resource_type - && share.resource_id.as_ref() == Some(&resource_oid) - && share.active - && !share.is_expired() - { - // Check if share has the required permission - let perm = match permission.to_lowercase().as_str() { - "read" => Permission::Read, - "write" => Permission::Write, - "delete" => Permission::Delete, - "share" => Permission::Share, - "admin" => Permission::Admin, - _ => return Ok(false), - }; - - if share.has_permission(&perm) { - return Ok(true); - } - } - } - - Ok(false) - } - - /// Check permission using a simplified interface - pub async fn check_permission( - &self, - user_id: &str, - resource_id: &str, - permission: &str, - ) -> Result { - // For now, check all resource types - let resource_types = ["profiles", "health_data", "lab_results", "medications"]; - - for resource_type in resource_types { - if self - .check_user_permission(user_id, resource_type, resource_id, permission) - .await? - { - return Ok(true); - } - } - - Ok(false) - } - // ===== Medication Methods (Fixed for Phase 2.8) ===== pub async fn create_medication(&self, medication: &Medication) -> Result> { diff --git a/backend/src/handlers/appointments.rs b/backend/src/handlers/appointments.rs index 9b8d709..7939fc4 100644 --- a/backend/src/handlers/appointments.rs +++ b/backend/src/handlers/appointments.rs @@ -65,39 +65,79 @@ pub async fn list_appointments( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, StatusCode> { +) -> Result>, (StatusCode, Json)> { let database = state.db.get_database(); let repo = AppointmentRepository::new(database.collection("appointments")); + // Resolve the effective owner. If a profile_id is specified, prefer direct + // ownership (the caller's own data for that profile); otherwise fall through + // to the share-gate (admits an active share recipient). profile_id is a + // free-form string at create time and may not map to a Profile document. + let owner = match &query.profile_id { + Some(pid) => { + let has_own = repo + .find_by_user_filtered(&claims.sub, None, Some(pid)) + .await + .map(|v| !v.is_empty()) + .unwrap_or(false); + if has_own { + claims.sub.clone() + } else { + crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await? + } + } + None => claims.sub.clone(), + }; + match repo - .find_by_user_filtered( - &claims.sub, - query.status.as_deref(), - query.profile_id.as_deref(), - ) + .find_by_user_filtered(&owner, query.status.as_deref(), query.profile_id.as_deref()) .await { Ok(appointments) => { let resp: Vec = appointments.into_iter().map(Into::into).collect(); Ok(Json(resp)) } - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(e) => { + tracing::error!("list_appointments failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } } } pub async fn get_appointment( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json)> { let database = state.db.get_database(); let repo = AppointmentRepository::new(database.collection("appointments")); - match repo.find_by_appointment_id(&id).await { - Ok(Some(appt)) => Ok(Json(appt.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + let appt = match repo.find_by_appointment_id(&id).await { + Ok(Some(a)) => a, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("get_appointment failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + // Authorization: direct owner (user_id == caller) OR share-gate on the + // appointment's profile (admits an active share recipient). + if appt.user_id != claims.sub { + crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id) + .await?; } + Ok(Json(appt.into())) } pub async fn update_appointment( diff --git a/backend/src/handlers/health_stats.rs b/backend/src/handlers/health_stats.rs index aa62870..5397d8e 100644 --- a/backend/src/handlers/health_stats.rs +++ b/backend/src/handlers/health_stats.rs @@ -90,8 +90,32 @@ pub async fn list_health_stats( .into_response() } }; + // Resolve the effective owner. If a profile_id is specified, prefer direct + // ownership (the caller's own data for that profile); otherwise fall through + // to the share-gate (admits an active share recipient). profile_id is a + // free-form string at create time and may not map to a Profile document. + let owner = match &query.profile_id { + Some(pid) => { + let has_own = repo + .find_by_user_filtered(&claims.sub, Some(pid)) + .await + .map(|v| !v.is_empty()) + .unwrap_or(false); + if has_own { + claims.sub.clone() + } else { + match crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid) + .await + { + Ok(o) => o, + Err(resp) => return resp.into_response(), + } + } + } + None => claims.sub.clone(), + }; match repo - .find_by_user_filtered(&claims.sub, query.profile_id.as_deref()) + .find_by_user_filtered(&owner, query.profile_id.as_deref()) .await { Ok(stats) => { @@ -134,10 +158,21 @@ pub async fn get_health_stat( match repo.find_by_id(&object_id).await { Ok(Some(stat)) => { - if stat.user_id != claims.sub { - return (StatusCode::FORBIDDEN, "Access denied").into_response(); + // Authorization: direct owner (user_id == caller) OR share-gate on + // the stat's profile (admits an active share recipient). + if stat.user_id == claims.sub { + return (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(); + } + match crate::handlers::profile_share::authorize_profile_read( + &state, + &claims, + &stat.profile_id, + ) + .await + { + Ok(_) => (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(), + Err(resp) => resp.into_response(), } - (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response() } Ok(None) => ( StatusCode::NOT_FOUND, diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 0d70c0e..2488c9a 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -64,38 +64,93 @@ pub async fn list_medications( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, StatusCode> { +) -> Result>, (StatusCode, Json)> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // Honor the active filter (and optional profile_id) when provided. + // Resolve the effective owner. If the caller specifies a profile_id, prefer + // direct ownership (the caller's own data for that profile); if they don't + // own it, fall through to the share-gate (admits an active share recipient + // and returns the profile's owner userId to query as). Without a profile_id, + // return the caller's own data across all their profiles. + // + // The direct-ownership-first check matters because a medication's profile_id + // is a free-form string at create time and may not map to a Profile document + // (legacy/test data) — the gate would 404 on those even for the real owner. + let owner = match &query.profile_id { + Some(pid) => { + let has_own = repo + .find_by_user_and_profile(&claims.sub, pid) + .await + .map(|v| !v.is_empty()) + .unwrap_or(false); + if has_own { + claims.sub.clone() + } else { + crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await? + } + } + None => claims.sub.clone(), + }; + match repo - .find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref()) + .find_by_user_filtered(&owner, query.active, query.profile_id.as_deref()) .await { Ok(medications) => { let resp: Vec = medications.into_iter().map(Into::into).collect(); Ok(Json(resp)) } - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(e) => { + tracing::error!("list_medications failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } } } pub async fn get_medication( State(state): State, - Extension(_claims): Extension, + Extension(claims): Extension, Path(id): Path, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json)> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); // The path param is the application-level medication_id (a UUID), not the // Mongo _id, so look it up directly instead of parsing as ObjectId. - match repo.find_by_medication_id(&id).await { - Ok(Some(medication)) => Ok(Json(medication.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + let medication = match repo.find_by_medication_id(&id).await { + Ok(Some(m)) => m, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not found" })), + )); + } + Err(e) => { + tracing::error!("get_medication failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + // Authorization: admit the direct owner (user_id == caller) OR, if not, + // fall through to the share-gate on the medication's profile (admits an + // active share recipient). The direct-owner check preserves the original + // ownership model for medications whose profile_id may not map to a real + // Profile document (e.g. legacy/test data). + if medication.user_id != claims.sub { + crate::handlers::profile_share::authorize_profile_read( + &state, + &claims, + &medication.profile_id, + ) + .await?; } + Ok(Json(medication.into())) } pub async fn update_medication( diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index e90ba39..665fe4e 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -4,10 +4,9 @@ pub mod health; pub mod health_stats; pub mod interactions; pub mod medications; -pub mod permissions; pub mod profile; +pub mod profile_share; pub mod sessions; -pub mod shares; pub mod users; // Re-export commonly used handler functions @@ -24,10 +23,12 @@ pub use medications::{ create_medication, delete_medication, get_adherence, get_medication, list_medications, log_dose, update_medication, }; -pub use permissions::check_permission; pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile}; +pub use profile_share::{ + create_share, delete_share, get_profile_shared_aware, get_public_key, list_shared_with_me, + list_shares, +}; pub use sessions::{get_sessions, revoke_all_sessions, revoke_session}; -pub use shares::{create_share, delete_share, list_shares, update_share}; pub use users::{ change_password, delete_account, get_account, get_settings, update_account, update_settings, }; diff --git a/backend/src/handlers/permissions.rs b/backend/src/handlers/permissions.rs deleted file mode 100644 index 6a4c751..0000000 --- a/backend/src/handlers/permissions.rs +++ /dev/null @@ -1,62 +0,0 @@ -use axum::{ - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, - Extension, Json, -}; -use serde::{Deserialize, Serialize}; - -use crate::{auth::jwt::Claims, config::AppState}; - -#[derive(Debug, Deserialize)] -pub struct CheckPermissionQuery { - pub resource_type: String, - pub resource_id: String, - pub permission: String, -} - -#[derive(Debug, Serialize)] -pub struct PermissionCheckResponse { - pub has_permission: bool, - pub resource_type: String, - pub resource_id: String, - pub permission: String, -} - -pub async fn check_permission( - State(state): State, - Query(params): Query, - Extension(claims): Extension, -) -> impl IntoResponse { - let has_permission = match state - .db - .check_user_permission( - &claims.sub, - ¶ms.resource_type, - ¶ms.resource_id, - ¶ms.permission, - ) - .await - { - Ok(result) => result, - Err(e) => { - tracing::error!("Failed to check permission: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to check permission" - })), - ) - .into_response(); - } - }; - - let response = PermissionCheckResponse { - has_permission, - resource_type: params.resource_type, - resource_id: params.resource_id, - permission: params.permission, - }; - - (StatusCode::OK, Json(response)).into_response() -} diff --git a/backend/src/handlers/profile_share.rs b/backend/src/handlers/profile_share.rs new file mode 100644 index 0000000..d29e833 --- /dev/null +++ b/backend/src/handlers/profile_share.rs @@ -0,0 +1,513 @@ +//! 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, + })) +} diff --git a/backend/src/handlers/shares.rs b/backend/src/handlers/shares.rs deleted file mode 100644 index 4481f96..0000000 --- a/backend/src/handlers/shares.rs +++ /dev/null @@ -1,453 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Extension, Json, -}; -use mongodb::bson::oid::ObjectId; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -use crate::{ - auth::jwt::Claims, - config::AppState, - models::{permission::Permission, share::Share}, -}; - -#[derive(Debug, Deserialize, Validate)] -pub struct CreateShareRequest { - pub target_user_email: String, - pub resource_type: String, - pub resource_id: Option, - pub permissions: Vec, - #[serde(default)] - pub expires_days: Option, -} - -#[derive(Debug, Serialize)] -pub struct ShareResponse { - pub id: String, - pub target_user_id: String, - pub resource_type: String, - pub resource_id: Option, - pub permissions: Vec, - pub expires_at: Option, - pub created_at: i64, - pub active: bool, -} - -impl TryFrom for ShareResponse { - type Error = anyhow::Error; - - fn try_from(share: Share) -> Result { - Ok(Self { - id: share.id.map(|id| id.to_string()).unwrap_or_default(), - target_user_id: share.target_user_id.to_string(), - resource_type: share.resource_type, - resource_id: share.resource_id.map(|id| id.to_string()), - permissions: share - .permissions - .into_iter() - .map(|p| p.to_string()) - .collect(), - expires_at: share.expires_at.map(|dt| dt.timestamp_millis()), - created_at: share.created_at.timestamp_millis(), - active: share.active, - }) - } -} - -pub async fn create_share( - State(state): State, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - if let Err(errors) = req.validate() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "validation failed", - "details": errors.to_string() - })), - ) - .into_response(); - } - - // Find target user by email - let target_user = match state.db.find_user_by_email(&req.target_user_email).await { - Ok(Some(user)) => user, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "target user not found" - })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to find target user: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "database error" - })), - ) - .into_response(); - } - }; - - let target_user_id = match target_user.id { - Some(id) => id, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "target user has no ID" - })), - ) - .into_response(); - } - }; - - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid user ID format" - })), - ) - .into_response(); - } - }; - - // Parse resource_id if provided - let resource_id = match req.resource_id { - Some(id) => match ObjectId::parse_str(&id) { - Ok(oid) => Some(oid), - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid resource_id format" - })), - ) - .into_response(); - } - }, - None => None, - }; - - // Parse permissions - support all permission types - let permissions: Vec = req - .permissions - .into_iter() - .filter_map(|p| match p.to_lowercase().as_str() { - "read" => Some(Permission::Read), - "write" => Some(Permission::Write), - "delete" => Some(Permission::Delete), - "share" => Some(Permission::Share), - "admin" => Some(Permission::Admin), - _ => None, - }) - .collect(); - - if permissions.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "at least one valid permission is required (read, write, delete, share, admin)" - }))).into_response(); - } - - // Calculate expiration - let expires_at = req.expires_days.map(|days| { - mongodb::bson::DateTime::now().saturating_add_millis(days as i64 * 24 * 60 * 60 * 1000) - }); - - let share = Share::new( - owner_id, - target_user_id, - req.resource_type, - resource_id, - permissions, - expires_at, - ); - - match state.db.create_share(&share).await { - Ok(_) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to create share response" - })), - ) - .into_response(); - } - }; - (StatusCode::CREATED, Json(response)).into_response() - } - Err(e) => { - tracing::error!("Failed to create share: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to create share" - })), - ) - .into_response() - } - } -} - -pub async fn list_shares( - State(state): State, - Extension(claims): Extension, -) -> impl IntoResponse { - let user_id = &claims.sub; - - match state.db.list_shares_for_user(user_id).await { - Ok(shares) => { - let responses: Vec = shares - .into_iter() - .filter_map(|s| ShareResponse::try_from(s).ok()) - .collect(); - (StatusCode::OK, Json(responses)).into_response() - } - Err(e) => { - tracing::error!("Failed to list shares: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to list shares" - })), - ) - .into_response() - } - } -} - -pub async fn get_share(State(state): State, Path(id): Path) -> impl IntoResponse { - match state.db.get_share(&id).await { - Ok(Some(share)) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to create share response" - })), - ) - .into_response(); - } - }; - (StatusCode::OK, Json(response)).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "share not found" - })), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get share: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to get share" - })), - ) - .into_response() - } - } -} - -#[derive(Debug, Deserialize, Validate)] -pub struct UpdateShareRequest { - pub permissions: Option>, - #[serde(default)] - pub active: Option, - #[serde(default)] - pub expires_days: Option, -} - -pub async fn update_share( - State(state): State, - Path(id): Path, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - // First get the share - let mut share = match state.db.get_share(&id).await { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "share not found" - })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get share: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to get share" - })), - ) - .into_response(); - } - }; - - // Verify ownership - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid user ID format" - })), - ) - .into_response(); - } - }; - - if share.owner_id != owner_id { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "not authorized to modify this share" - })), - ) - .into_response(); - } - - // Update fields - if let Some(permissions) = req.permissions { - share.permissions = permissions - .into_iter() - .filter_map(|p| match p.to_lowercase().as_str() { - "read" => Some(Permission::Read), - "write" => Some(Permission::Write), - "delete" => Some(Permission::Delete), - "share" => Some(Permission::Share), - "admin" => Some(Permission::Admin), - _ => None, - }) - .collect(); - } - - if let Some(active) = req.active { - share.active = active; - } - - if let Some(days) = req.expires_days { - share.expires_at = Some( - mongodb::bson::DateTime::now().saturating_add_millis(days as i64 * 24 * 60 * 60 * 1000), - ); - } - - match state.db.update_share(&share).await { - Ok(_) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to create share response" - })), - ) - .into_response(); - } - }; - (StatusCode::OK, Json(response)).into_response() - } - Err(e) => { - tracing::error!("Failed to update share: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to update share" - })), - ) - .into_response() - } - } -} - -pub async fn delete_share( - State(state): State, - Path(id): Path, - Extension(claims): Extension, -) -> impl IntoResponse { - // First get the share to verify ownership - let share = match state.db.get_share(&id).await { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "share not found" - })), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to get share: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to get share" - })), - ) - .into_response(); - } - }; - - // Verify ownership - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid user ID format" - })), - ) - .into_response(); - } - }; - - if share.owner_id != owner_id { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "not authorized to delete this share" - })), - ) - .into_response(); - } - - match state.db.delete_share(&id).await { - Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(), - Err(e) => { - tracing::error!("Failed to delete share: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "failed to delete share" - })), - ) - .into_response() - } - } -} diff --git a/backend/src/middleware/permission.rs b/backend/src/middleware/permission.rs deleted file mode 100644 index d265054..0000000 --- a/backend/src/middleware/permission.rs +++ /dev/null @@ -1,96 +0,0 @@ -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; -use crate::config::AppState; -use crate::auth::Claims; - -/// Middleware to check if user has permission for a resource -/// -/// This middleware checks JWT claims (attached by auth middleware) -/// and verifies the user has the required permission level. -/// -/// # Permission Levels -/// - "read": Can view resource -/// - "write": Can modify resource -/// - "admin": Full control including deletion -pub async fn has_permission( - State(state): State, - required_permission: String, - request: Request, - next: Next, -) -> Result { - // Extract user_id from JWT claims (attached by auth middleware) - let user_id = match request.extensions().get::() { - Some(claims) => claims.sub.clone(), - None => return Err(StatusCode::UNAUTHORIZED), - }; - - // Extract resource_id from URL path - let resource_id = match extract_resource_id(request.uri().path()) { - Some(id) => id, - None => return Err(StatusCode::BAD_REQUEST), - }; - - // Check if user has the required permission (either directly or through shares) - let has_perm = match state.db - .check_permission(&user_id, &resource_id, &required_permission) - .await - { - Ok(allowed) => allowed, - Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), - }; - - if !has_perm { - return Err(StatusCode::FORBIDDEN); - } - - Ok(next.run(request).await) -} - -/// Extract resource ID from URL path -/// -/// # Examples -/// - /api/shares/123 -> Some("123") -/// - /api/users/me/profile -> None -fn extract_resource_id(path: &str) -> Option { - let segments: Vec<&str> = path.split('/').collect(); - - // Look for ID segment after a resource type - // e.g., /api/shares/:id - for (i, segment) in segments.iter().enumerate() { - if segment == &"shares" || segment == &"permissions" { - if i + 1 < segments.len() { - let id = segments[i + 1]; - if !id.is_empty() { - return Some(id.to_string()); - } - } - } - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_resource_id() { - assert_eq!( - extract_resource_id("/api/shares/123"), - Some("123".to_string()) - ); - assert_eq!( - extract_resource_id("/api/shares/abc-123"), - Some("abc-123".to_string()) - ); - assert_eq!( - extract_resource_id("/api/users/me"), - None - ); - } -} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index bc326e8..175d86d 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -6,9 +6,8 @@ pub mod health_stats; pub mod interactions; pub mod lab_result; pub mod medication; -pub mod permission; pub mod profile; +pub mod profile_share; pub mod refresh_token; pub mod session; -pub mod share; pub mod user; diff --git a/backend/src/models/permission.rs b/backend/src/models/permission.rs deleted file mode 100644 index db0513c..0000000 --- a/backend/src/models/permission.rs +++ /dev/null @@ -1,44 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::fmt; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum Permission { - Read, - Write, - Delete, - Share, - Admin, -} - -impl fmt::Display for Permission { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Read => write!(f, "read"), - Self::Write => write!(f, "write"), - Self::Delete => write!(f, "delete"), - Self::Share => write!(f, "share"), - Self::Admin => write!(f, "admin"), - } - } -} - -impl Permission { - pub fn can_read(&self) -> bool { - matches!(self, Self::Read | Self::Admin) - } - - pub fn can_write(&self) -> bool { - matches!(self, Self::Write | Self::Admin) - } - - pub fn can_delete(&self) -> bool { - matches!(self, Self::Delete | Self::Admin) - } - - pub fn can_share(&self) -> bool { - matches!(self, Self::Share | Self::Admin) - } -} - -pub type Permissions = Vec; diff --git a/backend/src/models/profile_share.rs b/backend/src/models/profile_share.rs new file mode 100644 index 0000000..f3929c7 --- /dev/null +++ b/backend/src/models/profile_share.rs @@ -0,0 +1,179 @@ +use futures::stream::StreamExt; +use mongodb::{ + bson::{doc, oid::ObjectId, DateTime}, + Collection, +}; +use serde::{Deserialize, Serialize}; + +/// A profile shared from one account (owner) to another (recipient). +/// +/// Zero-knowledge envelope (Phase B, see `docs/adr/multi-person-sharing.md`): +/// the owner wraps the profile DEK to the recipient's X25519 identity public +/// key via ECDH, using a fresh ephemeral keypair per share. The server stores +/// only opaque ciphertext + public keys and cannot read the profile DEK. +/// +/// `permissions` reserves `read` (Phase B), `write`, and `admin` for later +/// phases. Phase B only grants read access. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileShare { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(rename = "profileId")] + pub profile_id: String, + /// Owner's account id (the profile's owner). Denormalized from the + /// profile for query efficiency without a join. + #[serde(rename = "ownerUserId")] + pub owner_user_id: String, + /// Recipient's account id. + #[serde(rename = "recipientUserId")] + pub recipient_user_id: String, + /// Ephemeral X25519 public key (base64 raw) generated for this share. The + /// recipient combines it with their identity private key to ECDH-derive + /// the wrapping key. Plaintext — public keys are not secret. + #[serde(rename = "ephemeralPublicKey")] + pub ephemeral_public_key: String, + /// Profile DEK wrapped (AES-256-GCM) under the ECDH-derived key. Opaque. + #[serde(rename = "wrappedProfileDek")] + pub wrapped_profile_dek: String, + #[serde(rename = "wrappedProfileDekIv")] + pub wrapped_profile_dek_iv: String, + /// Reserved for later phases. Phase B writes `["read"]`. + #[serde(rename = "permissions", default = "default_read")] + pub permissions: Vec, + /// Optional expiry; if set and past, `find_active` treats the share as gone. + #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(rename = "createdAt")] + pub created_at: DateTime, + /// Supports a future "disable without delete" path. Phase B soft-revoke + /// hard-deletes the doc, but the field is kept for forward-compat. + #[serde(rename = "active", default = "default_true")] + pub active: bool, +} + +fn default_read() -> Vec { + vec!["read".to_string()] +} + +fn default_true() -> bool { + true +} + +pub struct ProfileShareRepository { + collection: Collection, +} + +impl ProfileShareRepository { + pub fn new(collection: Collection) -> Self { + Self { collection } + } + + pub async fn create(&self, share: &ProfileShare) -> mongodb::error::Result<()> { + self.collection.insert_one(share, None).await?; + Ok(()) + } + + /// All shares where the given account is the recipient (for the + /// `/profiles/shared-with-me` endpoint). + pub async fn find_for_recipient( + &self, + recipient_user_id: &str, + ) -> mongodb::error::Result> { + let mut cursor = self + .collection + .find(doc! { "recipientUserId": recipient_user_id }, None) + .await?; + let mut out = Vec::new(); + while let Some(s) = cursor.next().await { + out.push(s?); + } + Ok(out) + } + + /// All shares for a given profile (owner listing who they've shared with). + pub async fn find_for_profile( + &self, + profile_id: &str, + ) -> mongodb::error::Result> { + let mut cursor = self + .collection + .find(doc! { "profileId": profile_id }, None) + .await?; + let mut out = Vec::new(); + while let Some(s) = cursor.next().await { + out.push(s?); + } + Ok(out) + } + + /// A specific (profile, recipient) share regardless of active/expiry state. + pub async fn find( + &self, + profile_id: &str, + recipient_user_id: &str, + ) -> mongodb::error::Result> { + self.collection + .find_one( + doc! { "profileId": profile_id, "recipientUserId": recipient_user_id }, + None, + ) + .await + } + + /// A specific (profile, recipient) share, only if currently usable: + /// `active == true` and not past `expires_at`. Used by the share-gate. + pub async fn find_active( + &self, + profile_id: &str, + recipient_user_id: &str, + ) -> mongodb::error::Result> { + let now = DateTime::now(); + // active==true AND (expiresAt missing OR expiresAt > now) + let filter = doc! { + "profileId": profile_id, + "recipientUserId": recipient_user_id, + "active": true, + "$or": [ + { "expiresAt": { "$exists": false } }, + { "expiresAt": null }, + { "expiresAt": { "$gt": now } }, + ], + }; + self.collection.find_one(filter, None).await + } + + /// Hard-delete (soft-revoke) the (profile, recipient) share. Returns true + /// if a doc was deleted. + pub async fn delete( + &self, + profile_id: &str, + recipient_user_id: &str, + ) -> mongodb::error::Result { + let res = self + .collection + .delete_one( + doc! { "profileId": profile_id, "recipientUserId": recipient_user_id }, + None, + ) + .await?; + Ok(res.deleted_count > 0) + } + + /// Upsert: replace any existing (profile, recipient) share with the given + /// one. Used when re-sharing (e.g. rotating the ephemeral key). Deletes + /// existing rows for the pair first, then inserts. + pub async fn upsert(&self, share: &ProfileShare) -> mongodb::error::Result<()> { + let _ = self + .collection + .delete_one( + doc! { + "profileId": &share.profile_id, + "recipientUserId": &share.recipient_user_id, + }, + None, + ) + .await?; + self.collection.insert_one(share, None).await?; + Ok(()) + } +} diff --git a/backend/src/models/share.rs b/backend/src/models/share.rs deleted file mode 100644 index 2a9df80..0000000 --- a/backend/src/models/share.rs +++ /dev/null @@ -1,123 +0,0 @@ -use mongodb::bson::DateTime; -use mongodb::bson::{doc, oid::ObjectId}; -use mongodb::Collection; -use serde::{Deserialize, Serialize}; - -use super::permission::Permission; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Share { - #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, - pub owner_id: ObjectId, - pub target_user_id: ObjectId, - pub resource_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub resource_id: Option, - pub permissions: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - pub created_at: DateTime, - pub active: bool, -} - -impl Share { - pub fn new( - owner_id: ObjectId, - target_user_id: ObjectId, - resource_type: String, - resource_id: Option, - permissions: Vec, - expires_at: Option, - ) -> Self { - Self { - id: None, - owner_id, - target_user_id, - resource_type, - resource_id, - permissions, - expires_at, - created_at: DateTime::now(), - active: true, - } - } - - pub fn is_expired(&self) -> bool { - if let Some(expires) = self.expires_at { - DateTime::now() > expires - } else { - false - } - } - - pub fn has_permission(&self, permission: &Permission) -> bool { - self.permissions.contains(permission) || self.permissions.contains(&Permission::Admin) - } - - pub fn revoke(&mut self) { - self.active = false; - } -} - -#[derive(Clone)] -pub struct ShareRepository { - collection: Collection, -} - -impl ShareRepository { - pub fn new(collection: Collection) -> Self { - Self { collection } - } - - pub async fn create(&self, share: &Share) -> mongodb::error::Result> { - let result = self.collection.insert_one(share, None).await?; - Ok(Some(result.inserted_id.as_object_id().unwrap())) - } - - pub async fn find_by_id(&self, id: &ObjectId) -> mongodb::error::Result> { - self.collection.find_one(doc! { "_id": id }, None).await - } - - pub async fn find_by_owner(&self, owner_id: &ObjectId) -> mongodb::error::Result> { - use futures::stream::TryStreamExt; - - self.collection - .find(doc! { "owner_id": owner_id }, None) - .await? - .try_collect() - .await - } - - pub async fn find_by_target( - &self, - target_user_id: &ObjectId, - ) -> mongodb::error::Result> { - use futures::stream::TryStreamExt; - - self.collection - .find( - doc! { "target_user_id": target_user_id, "active": true }, - None, - ) - .await? - .try_collect() - .await - } - - pub async fn update(&self, share: &Share) -> mongodb::error::Result<()> { - if let Some(id) = &share.id { - self.collection - .replace_one(doc! { "_id": id }, share, None) - .await?; - } - Ok(()) - } - - pub async fn delete(&self, share_id: &ObjectId) -> mongodb::error::Result<()> { - self.collection - .delete_one(doc! { "_id": share_id }, None) - .await?; - Ok(()) - } -} diff --git a/backend/tests/share_tests.rs b/backend/tests/share_tests.rs new file mode 100644 index 0000000..05d84a1 --- /dev/null +++ b/backend/tests/share_tests.rs @@ -0,0 +1,456 @@ +//! Profile-sharing integration tests (Phase B). +//! +//! Exercises the X25519 envelope endpoints and the share-gate: an owner +//! shares a profile to a recipient; the recipient sees it in +//! /profiles/shared-with-me and can read its data; revoke cuts off access. +//! All wrapped blobs are opaque strings — the server never inspects them, so +//! the test doesn't need real crypto. +//! +//! Requires a live MongoDB; skips gracefully otherwise. + +mod common; + +use serde_json::{json, Value}; + +macro_rules! require_app { + ($app:expr) => { + match $app { + Some(x) => x, + None => { + eprintln!("[integration] skipped (MongoDB unavailable)"); + return; + } + } + }; +} + +fn unique_email() -> String { + format!("test_{}@example.com", uuid::Uuid::new_v4()) +} + +/// URL-encode a value for a query string (emails contain '@'). +fn q(email: &str) -> String { + email.replace('@', "%40") +} + +/// Register a fully-set-up user (identity public key + a default self profile) +/// and return the access token. The owner profile is `profile_`. +async fn register_full(app: &axum::Router, email: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/auth/register", + Some(json!({ + "email": email, + "username": email, + "password": "supersecret", + "identity_public_key": format!("pubkey-{email}"), + "default_profile_name_data": "name-blob", + "default_profile_name_iv": "name-iv", + "default_wrapped_profile_dek": "dek-blob", + "default_wrapped_profile_dek_iv": "dek-iv", + })), + None, + ) + .await; + assert_eq!(status, 201, "register_full failed, body: {body}"); + body["token"].as_str().unwrap().to_string() +} + +/// Register a user with NO identity public key (simulates a pre-A1 account). +async fn register_no_key(app: &axum::Router, email: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/auth/register", + Some(json!({ "email": email, "username": email, "password": "supersecret" })), + None, + ) + .await; + assert_eq!(status, 201, "register_no_key failed, body: {body}"); + body["token"].as_str().unwrap().to_string() +} + +/// The owner's self profile id (deterministic contract). +fn self_profile_id(owner_token_response: &Value) -> String { + format!( + "profile_{}", + owner_token_response["user_id"].as_str().unwrap() + ) +} + +#[tokio::test] +async fn public_key_lookup_returns_recipient_identity_key() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_email = unique_email(); + let _token = register_full(&app, &owner_email).await; + + let (status, body) = common::send_json( + &app, + "GET", + &format!("/api/users/public-key?email={}", q(&owner_email)), + None, + None, + ) + .await; + assert_eq!(status, 200, "public-key lookup, body: {body}"); + assert_eq!(body["identity_public_key"], format!("pubkey-{owner_email}")); + assert!(!body["user_id"].as_str().unwrap().is_empty()); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn public_key_lookup_404s_for_unknown_or_keyless_user() { + let (app, db_name) = require_app!(common::app_for_test().await); + let keyless_email = unique_email(); + let _t = register_no_key(&app, &keyless_email).await; + + // Unknown address. + let (status, _) = common::send_json( + &app, + "GET", + "/api/users/public-key?email=does-not-exist%40example.com", + None, + None, + ) + .await; + assert_eq!(status, 404); + + // Existing user but no key. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/users/public-key?email={}", q(&keyless_email)), + None, + None, + ) + .await; + assert_eq!(status, 404); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn owner_shares_recipient_reads_owner_revokes() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_email = unique_email(); + let recipient_email = unique_email(); + + // Register owner + recipient (both with identity keys + self profiles). + let (_, owner_body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": owner_email, + "username": owner_email, + "password": "supersecret", + "identity_public_key": "owner-pub", + "default_profile_name_data": "n", "default_profile_name_iv": "i", + "default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v", + })), + None, + ) + .await; + let owner_token = owner_body["token"].as_str().unwrap().to_string(); + let owner_profile = self_profile_id(&owner_body); + + let recipient_token = register_full(&app, &recipient_email).await; + + // Owner shares their self profile to the recipient. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": recipient_email, + "ephemeral_public_key": "ephemeral-pub", + "wrapped_profile_dek": "wrapped-dek", + "wrapped_profile_dek_iv": "wrapped-iv", + "permissions": ["read"], + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 201, "share create should return 201"); + + // Recipient sees it under /profiles/shared-with-me. + let (status, body) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200, "shared-with-me, body: {body}"); + let arr = body.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["profile_id"], owner_profile); + assert_eq!(arr[0]["ephemeral_public_key"], "ephemeral-pub"); + assert_eq!(arr[0]["wrapped_profile_dek"], "wrapped-dek"); + + // Recipient can GET the shared profile (share-aware GET). + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{owner_profile}"), + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200, "recipient should read shared profile"); + + // Recipient can list the owner's medications for that profile (the owner + // has none, but the share-gate must admit the request rather than 404). + let (status, body) = common::send_json( + &app, + "GET", + &format!("/api/medications?profile_id={owner_profile}"), + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200, "recipient meds read, body: {body}"); + assert_eq!(body.as_array().unwrap().len(), 0); + + // Recipient attempts a write to the shared profile. The medication create + // handler (a) doesn't enforce ownership on writes yet (issue #12) and + // (b) returns 200 OK (not 201) on success. So the write currently succeeds + // but is attributed to the RECIPIENT's user_id, not the owner — meaning it + // does NOT pollute the owner's view of that profile. Accept 200 (current, + // #12 not fixed) or 403 (once #12 lands). Either way the owner's records + // must be untouched. + let (write_status, _body) = common::send_json( + &app, + "POST", + "/api/medications", + Some(json!({ + "profile_id": owner_profile, + "encrypted_data": { "data": "x", "iv": "y" }, + "active": true, + })), + Some(&recipient_token), + ) + .await; + assert!( + write_status == 200 || write_status == 403, + "write outcome: {write_status}" + ); + let (status, owner_meds) = common::send_json( + &app, + "GET", + &format!("/api/medications?profile_id={owner_profile}"), + None, + Some(&owner_token), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + owner_meds.as_array().unwrap().len(), + 0, + "owner's data untouched" + ); + + // Owner revokes. + // Look up the recipient's user id from the shares listing. + let (_, shares) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{owner_profile}/shares"), + None, + Some(&owner_token), + ) + .await; + let recipient_uid = shares[0]["recipient_user_id"].as_str().unwrap().to_string(); + + let (status, _) = common::send_json( + &app, + "DELETE", + &format!("/api/profiles/{owner_profile}/shares/{recipient_uid}"), + None, + Some(&owner_token), + ) + .await; + assert_eq!(status, 204, "revoke should return 204"); + + // Recipient now loses read access. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{owner_profile}"), + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 404, "revoked recipient should get 404"); + let (status, body) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + body.as_array().unwrap().len(), + 0, + "shared-with-me now empty" + ); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn share_rejects_bad_recipient_states() { + let (app, db_name) = require_app!(common::app_for_test().await); + let owner_email = unique_email(); + + let (_, owner_body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": owner_email, "username": owner_email, "password": "supersecret", + "identity_public_key": "owner-pub", + "default_profile_name_data": "n", "default_profile_name_iv": "i", + "default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v", + })), + None, + ) + .await; + let owner_token = owner_body["token"].as_str().unwrap().to_string(); + let owner_profile = self_profile_id(&owner_body); + + // Share to a nonexistent recipient → 404. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": "ghost@example.com", + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 404, "share to ghost should 404"); + + // Share to a user without an identity key → 404. + let keyless_email = unique_email(); + let _t = register_no_key(&app, &keyless_email).await; + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": keyless_email, + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 404, "share to keyless user should 404"); + + // Share to self → 400. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": owner_email, + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 400, "share to self should 400"); + + // Non-owner cannot create a share for someone else's profile. + let other_token = register_full(&app, &unique_email()).await; + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": unique_email(), + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + })), + Some(&other_token), + ) + .await; + assert_eq!( + status, 404, + "non-owner share attempt should 404 (profile not found for them)" + ); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn expired_share_is_treated_as_absent() { + let (app, db_name) = require_app!(common::app_for_test().await); + + let (_, owner_body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": unique_email(), "username": "owner", "password": "supersecret", + "identity_public_key": "owner-pub", + "default_profile_name_data": "n", "default_profile_name_iv": "i", + "default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v", + })), + None, + ) + .await; + let owner_token = owner_body["token"].as_str().unwrap().to_string(); + let owner_profile = self_profile_id(&owner_body); + + let recipient_email = unique_email(); + let recipient_token = register_full(&app, &recipient_email).await; + + // Share with an expiry in the past. + let (status, _) = common::send_json( + &app, + "POST", + &format!("/api/profiles/{owner_profile}/shares"), + Some(json!({ + "recipient_email": recipient_email, + "ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v", + "expires_at": "2020-01-01T00:00:00Z", + })), + Some(&owner_token), + ) + .await; + assert_eq!(status, 201); + + // Recipient cannot read — expired share is invisible to the gate. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{owner_profile}"), + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 404, "expired share should not grant access"); + let (status, body) = common::send_json( + &app, + "GET", + "/api/profiles/shared-with-me", + None, + Some(&recipient_token), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + body.as_array().unwrap().len(), + 0, + "expired share hidden from list" + ); + + common::drop_test_db(&db_name).await; +} diff --git a/web/normogen-web/src/components/profile/ProfileEditor.tsx b/web/normogen-web/src/components/profile/ProfileEditor.tsx index 8876081..d070990 100644 --- a/web/normogen-web/src/components/profile/ProfileEditor.tsx +++ b/web/normogen-web/src/components/profile/ProfileEditor.tsx @@ -16,6 +16,7 @@ import SaveIcon from '@mui/icons-material/Save'; import AddIcon from '@mui/icons-material/Add'; import DeleteIcon from '@mui/icons-material/Delete'; import { useProfileStore, useAuthStore } from '../../store/useStore'; +import { ProfileSharing } from './ProfileSharing'; export const ProfileEditor: FC = () => { const { @@ -150,7 +151,11 @@ export const ProfileEditor: FC = () => { ) : ( {active.name || user?.username || '—'} - + {active.is_shared ? ( + + ) : ( + + )} )} @@ -198,25 +203,44 @@ export const ProfileEditor: FC = () => { )} - - Account - {user?.email} - - - Profile ID: {active.profile_id} - - - - + {active.is_shared ? ( + + Owner + {active.owner_account_id} + + You have read-only access to this shared profile. + + + ) : ( + <> + + Account + {user?.email} + + + Profile ID: {active.profile_id} + + + + + + )} )} + + {/* Owner-only sharing UI. */} + {!editing && !active.is_shared && ( + + + + )} diff --git a/web/normogen-web/src/components/profile/ProfileSharing.tsx b/web/normogen-web/src/components/profile/ProfileSharing.tsx new file mode 100644 index 0000000..12214fe --- /dev/null +++ b/web/normogen-web/src/components/profile/ProfileSharing.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState, type FC } from 'react'; +import { + Box, + Button, + CircularProgress, + Alert, + IconButton, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Stack, + TextField, + Typography, +} from '@mui/material'; +import PersonAddIcon from '@mui/icons-material/PersonAdd'; +import PersonRemoveIcon from '@mui/icons-material/PersonRemove'; +import { useProfileStore } from '../../store/useStore'; + +/** Owner-side sharing UI for a single owned profile: lists the recipients the + * profile is shared with (with revoke buttons) and an "add recipient by + * email" field. Shown only for profiles the current user owns. Recipients + * get the profile via /profiles/shared-with-me + the ECDH-wrapped DEK. */ +export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => { + const { + profileShares, + loadProfileShares, + shareProfile, + revokeShare, + error, + clearError, + } = useProfileStore(); + + const [recipientEmail, setRecipientEmail] = useState(''); + const [busy, setBusy] = useState(false); + const [localError, setLocalError] = useState(''); + + useEffect(() => { + loadProfileShares(profileId); + }, [profileId, loadProfileShares]); + + const shares = profileShares[profileId] ?? []; + + const handleAdd = async () => { + setBusy(true); + setLocalError(''); + try { + await shareProfile(profileId, recipientEmail.trim()); + setRecipientEmail(''); + } catch (e: any) { + setLocalError(e?.message || 'Failed to share'); + } finally { + setBusy(false); + } + }; + + const handleRevoke = async (recipientUserId: string) => { + if (!confirm('Revoke this share? The recipient will lose access immediately.')) return; + setBusy(true); + try { + await revokeShare(profileId, recipientUserId); + } catch { + /* surfaced via store error */ + } finally { + setBusy(false); + } + }; + + return ( + + + Shared with + + + {(error || localError) && ( + { + setLocalError(''); + clearError(); + }} + > + {localError || error} + + )} + + {shares.length === 0 ? ( + + Not shared with anyone. + + ) : ( + + {shares.map((s) => ( + + + + handleRevoke(s.recipient_user_id)} + > + + + + + ))} + + )} + + + setRecipientEmail(e.target.value)} + /> + + + + The recipient must have a Normogen account. They'll see this profile in their switcher. + + + ); +}; + +export default ProfileSharing; diff --git a/web/normogen-web/src/components/profile/ProfileSwitcher.tsx b/web/normogen-web/src/components/profile/ProfileSwitcher.tsx index 08ec1e2..fa1c00a 100644 --- a/web/normogen-web/src/components/profile/ProfileSwitcher.tsx +++ b/web/normogen-web/src/components/profile/ProfileSwitcher.tsx @@ -1,17 +1,23 @@ import { useEffect, type FC } from 'react'; -import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material'; +import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material'; import { useProfileStore } from '../../store/useStore'; /** A compact dropdown for switching the active profile (the subject of care * whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles` - * on mount so the list + per-profile DEKs are ready. */ + * + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */ export const ProfileSwitcher: FC = () => { - const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } = + const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } = useProfileStore(); useEffect(() => { - loadProfiles(); - }, [loadProfiles]); + (async () => { + await loadProfiles(); + // Fetch profiles shared TO me after owned profiles are loaded (the + // identity private key must be unwrapped first; loadSharedWithMe is a + // no-op if it isn't available yet). + await loadSharedWithMe(); + })(); + }, [loadProfiles, loadSharedWithMe]); if (profiles.length === 0) { return isLoading ? ( @@ -36,10 +42,21 @@ export const ProfileSwitcher: FC = () => { '.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' }, '.MuiSvgIcon-root': { color: 'common.white' }, }} + renderValue={(value) => { + const p = profiles.find((x) => x.profile_id === value); + return p ? (p.name || p.relationship || p.profile_id) : ''; + }} > {profiles.map((p) => ( - {p.name || p.relationship || p.profile_id} + {p.name || p.relationship || p.profile_id} + {p.is_shared && ( + + )} ))} diff --git a/web/normogen-web/src/crypto/crypto.e2e.test.ts b/web/normogen-web/src/crypto/crypto.e2e.test.ts index 5ba47a0..4430b44 100644 --- a/web/normogen-web/src/crypto/crypto.e2e.test.ts +++ b/web/normogen-web/src/crypto/crypto.e2e.test.ts @@ -20,6 +20,8 @@ import { generateProfileDek, wrapProfileDek, unwrapProfileDek, + wrapProfileDekToRecipient, + unwrapProfileDekFromShare, setProfileDek, getProfileDek, clearProfileDeks, @@ -282,3 +284,52 @@ describe('per-profile DEK isolation (Phase A2)', () => { expect(getProfileDek('profile_child')).toBeNull(); }); }); + +describe('profile sharing: X25519 envelope (Phase B)', () => { + it('owner wraps a profile DEK to a recipient, who unwraps it', async () => { + if (!(await x25519Supported())) { + console.log('[crypto] skipped (X25519 unsupported in this runtime)'); + return; + } + // Two accounts, each with their own identity keypair. + const owner = await generateIdentityKeyPair(); + const recipient = await generateIdentityKeyPair(); + + // A profile DEK the owner created (would normally be wrapped under the + // owner's account DEK too — irrelevant for this test). + const profileDek = await generateProfileDek(); + + // Owner wraps the profile DEK to the recipient's public key. Internally + // this derives a fresh ephemeral keypair + ECDH(recipient pub, ephemeral + // priv) and AES-GCM-wraps the DEK under the shared secret. + const envelope = await wrapProfileDekToRecipient( + profileDek, + recipient.publicKey, + owner.privateKey, // not actually used by wrap (only recipient pub matters) + ); + expect(envelope.ephemeralPublicKey).not.toBe(''); + expect(envelope.wrappedProfileDek.data).not.toBe(''); + + // Recipient unwraps with their private key + the envelope's ephemeral pub. + const recoveredDek = await unwrapProfileDekFromShare( + envelope.wrappedProfileDek, + envelope.ephemeralPublicKey, + recipient.privateKey, + ); + expect(recoveredDek).toBeDefined(); + + // The recovered DEK encrypts/decrypts the same data the original did. + const data = await encrypt('shared-medication', profileDek); + expect(await decrypt(data, recoveredDek)).toBe('shared-medication'); + + // A *different* recipient's private key cannot unwrap the share. + const stranger = await generateIdentityKeyPair(); + await expect( + unwrapProfileDekFromShare( + envelope.wrappedProfileDek, + envelope.ephemeralPublicKey, + stranger.privateKey, + ), + ).rejects.toThrow(); + }); +}); diff --git a/web/normogen-web/src/crypto/index.ts b/web/normogen-web/src/crypto/index.ts index 7f12d62..798cec2 100644 --- a/web/normogen-web/src/crypto/index.ts +++ b/web/normogen-web/src/crypto/index.ts @@ -14,6 +14,9 @@ export { generateProfileDek, wrapProfileDek, unwrapProfileDek, + wrapProfileDekToRecipient, + unwrapProfileDekFromShare, + type SharedProfileDekEnvelope, setEncKey, getEncKey, clearEncKey, diff --git a/web/normogen-web/src/crypto/keys.ts b/web/normogen-web/src/crypto/keys.ts index c946be3..b96d09d 100644 --- a/web/normogen-web/src/crypto/keys.ts +++ b/web/normogen-web/src/crypto/keys.ts @@ -147,6 +147,97 @@ export async function unwrapProfileDek( return unwrapDek(payload, accountDek); } +// --------------------------------------------------------------------------- +// Profile sharing: X25519 envelope (Phase B). +// +// To share a profile, the owner generates a fresh ephemeral X25519 keypair, +// ECDH-derives a shared secret from (ephemeral private, recipient public), +// uses it as an AES-GCM wrapping key, and wraps the profile DEK. The server +// stores the wrapped DEK + the ephemeral public key (plaintext). The recipient +// ECDH-derives the same shared secret from (their private, ephemeral public) +// and unwraps the profile DEK. See docs/adr/multi-person-sharing.md §3. +// --------------------------------------------------------------------------- + +/** Shape returned by the owner-side share wrapping. */ +export interface SharedProfileDekEnvelope { + /** base64 ephemeral X25519 public key (send to the server plaintext). */ + ephemeralPublicKey: string; + /** Profile DEK wrapped under the ECDH-derived key. */ + wrappedProfileDek: CipherPayload; +} + +/** Import a base64-raw X25519 public key for ECDH. */ +async function importX25519Public(publicKeyB64: string): Promise { + const raw = Uint8Array.from(atob(publicKeyB64), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey( + 'raw', + raw as BufferSource, + { name: 'ECDH', namedCurve: 'X25519' }, + true, + [], + ); +} + +/** Owner: wrap a profile DEK to a recipient's identity public key. Generates + * a fresh ephemeral keypair per share. Requires the owner's identity private + * key (in-memory via getIdentityPrivate()). */ +export async function wrapProfileDekToRecipient( + profileDek: CryptoKey, + recipientPublicKeyB64: string, + myIdentityPrivate: CryptoKey, +): Promise { + // Fresh ephemeral keypair for this share (forward secrecy within its lifetime). + const ephemeral = (await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'X25519' }, + true, + ['deriveBits'], + )) as CryptoKeyPair; + const recipientPub = await importX25519Public(recipientPublicKeyB64); + // ECDH(ephemeral private, recipient public) -> shared secret -> AES-GCM key. + const sharedBits = await crypto.subtle.deriveBits( + { name: 'ECDH', public: recipientPub }, + ephemeral.privateKey, + 256, + ); + const wrapKey = await crypto.subtle.importKey( + 'raw', + sharedBits, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + const wrappedProfileDek = await wrapDek(profileDek, wrapKey); + const rawEphemeralPub = await crypto.subtle.exportKey('raw', ephemeral.publicKey); + return { + ephemeralPublicKey: base64(new Uint8Array(rawEphemeralPub)), + wrappedProfileDek, + }; +} + +/** Recipient: unwrap a profile DEK from a share using the recipient's identity + * private key + the share's ephemeral public key. Inverse of + * wrapProfileDekToRecipient. Throws on tamper / wrong keys. */ +export async function unwrapProfileDekFromShare( + wrapped: CipherPayload, + ephemeralPublicKeyB64: string, + myIdentityPrivate: CryptoKey, +): Promise { + const ephemeralPub = await importX25519Public(ephemeralPublicKeyB64); + const sharedBits = await crypto.subtle.deriveBits( + { name: 'ECDH', public: ephemeralPub }, + myIdentityPrivate, + 256, + ); + const wrapKey = await crypto.subtle.importKey( + 'raw', + sharedBits, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + return unwrapDek(wrapped, wrapKey); +} + // --------------------------------------------------------------------------- // High-level flows // --------------------------------------------------------------------------- diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index df31074..753fab8 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -20,6 +20,10 @@ import { ProfileWireResponse, CreateProfileRequest, UpdateProfileRequest, + SharedProfileWireResponse, + CreateProfileShareRequest, + ProfileShareListing, + PublicKeyResponse, HealthStatWireResponse, UpdateHealthStatRequest, EncryptedFieldWire, @@ -260,6 +264,50 @@ class ApiService { await this.client.delete(`/profiles/${profileId}`); } + // ---- Profile sharing (Phase B: X25519 envelope) ---- + + /** Public endpoint — recipient identity public key lookup by email. */ + async getUserPublicKey(email: string): Promise { + const response = await this.client.get('/users/public-key', { + params: { email }, + }); + return response.data; + } + + /** Profiles shared TO the current user. Carries ECDH-wrapped profile DEKs. */ + async listSharedWithMe(): Promise { + const response = await this.client.get( + '/profiles/shared-with-me', + ); + return response.data; + } + + /** Owner: list who a profile has been shared with. */ + async listProfileShares(profileId: string): Promise { + const response = await this.client.get( + `/profiles/${profileId}/shares`, + ); + return response.data; + } + + /** Owner: share a profile. The wrapped DEK + ephemeral pubkey are produced + * client-side via wrapProfileDekToRecipient. */ + async createProfileShare( + profileId: string, + req: CreateProfileShareRequest, + ): Promise { + const response = await this.client.post( + `/profiles/${profileId}/shares`, + req, + ); + return response.data; + } + + /** Owner: revoke a share (soft revoke — server deletes the share record). */ + async deleteProfileShare(profileId: string, recipientUserId: string): Promise { + await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`); + } + // ---- Medications (zero-knowledge: opaque encrypted blobs) ---- async getMedications(profileId?: string): Promise { diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index a88b309..a7692b1 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -12,6 +12,8 @@ import { generateProfileDek, wrapProfileDek, unwrapProfileDek, + wrapProfileDekToRecipient, + unwrapProfileDekFromShare, encrypt as encryptRaw, decrypt as decryptRaw, setEncKey, @@ -19,6 +21,7 @@ import { clearIdentityPrivate, clearProfileDeks, setIdentityPrivate, + getIdentityPrivate, getEncKey, getActiveProfileDek, getActiveProfileId, @@ -37,6 +40,7 @@ import { DrugInteraction, AdherenceStats, Profile, + ProfileShareListing, Appointment, CreateAppointmentRequest, UpdateAppointmentRequest, @@ -137,6 +141,18 @@ interface ProfileState { relationship?: string; }) => Promise; deleteProfile: (profileId: string) => Promise; + /** Fetch profiles shared TO the current user and merge them into `profiles`, + * unwrapping each share's profile DEK via the recipient's identity private + * key. Called after loadProfiles on login/unlock. */ + loadSharedWithMe: () => Promise; + /** Owner: share a profile to a recipient by email. Fetches the recipient's + * identity public key, wraps the profile DEK to it via ECDH, and POSTs. */ + shareProfile: (profileId: string, recipientEmail: string) => Promise; + /** Owner: revoke a share (delete the share record server-side). */ + revokeShare: (profileId: string, recipientUserId: string) => Promise; + /** Shares the current user has created for a profile (for the owner UI). */ + profileShares: Record; + loadProfileShares: (profileId: string) => Promise; clearError: () => void; } @@ -805,6 +821,7 @@ export const useProfileStore = create()( activeProfileId: null, isLoading: false, error: null, + profileShares: {}, loadProfiles: async () => { const accountDek = getEncKey(); @@ -980,6 +997,130 @@ export const useProfileStore = create()( } }, + loadSharedWithMe: async () => { + // Recipient path: profiles shared TO the current user. Each share's + // profile DEK is wrapped under an ECDH-derived key; unwrap it with the + // identity private key, then decrypt the display name under the profile + // DEK. Merge into the profiles list with is_shared = true. + const myIdentityPrivate = getIdentityPrivate(); + if (!myIdentityPrivate) { + // No identity key unlocked — can't unwrap shared profile DEKs. Silently + // skip; the owned-profile list still loads. + return; + } + try { + const shared = await apiService.listSharedWithMe(); + const sharedProfiles: Profile[] = []; + for (const s of shared) { + // Skip if we already have the DEK in memory (avoid re-unwrapping). + let profileDek = getProfileDek(s.profile_id); + if (!profileDek) { + try { + profileDek = await unwrapProfileDekFromShare( + { data: s.wrapped_profile_dek, iv: s.wrapped_profile_dek_iv }, + s.ephemeral_public_key, + myIdentityPrivate, + ); + setProfileDek(s.profile_id, profileDek); + } catch { + continue; // couldn't unwrap — skip this share + } + } + let name = ''; + if (s.name_data && s.name_iv && profileDek) { + try { + name = await decryptRaw({ data: s.name_data, iv: s.name_iv }, profileDek); + } catch { name = ''; } + } + sharedProfiles.push({ + profile_id: s.profile_id, + owner_account_id: s.owner_account_id, + name, + kind: s.kind, + relationship: s.relationship, + role: 'patient', + permissions: s.permissions, + created_at: s.created_at, + updated_at: s.created_at, + is_shared: true, + }); + } + // Merge: replace any prior shared entries, keep owned ones intact. + const owned = get().profiles.filter((p) => !p.is_shared); + const merged = [...owned, ...sharedProfiles]; + set({ profiles: merged }); + } catch (error: any) { + // Non-fatal: owned profiles still work. + set({ error: error.message || 'Failed to load shared profiles' }); + } + }, + + shareProfile: async (profileId, recipientEmail) => { + // Owner path: fetch recipient pubkey, wrap the profile DEK to it, POST. + const myIdentityPrivate = getIdentityPrivate(); + const profileDek = getProfileDek(profileId); + if (!myIdentityPrivate) { + set({ error: 'No identity key unlocked — cannot share' }); + throw new Error('No identity key'); + } + if (!profileDek) { + set({ error: 'No profile key — switch to the profile first' }); + throw new Error('No profile key'); + } + set({ isLoading: true, error: null }); + try { + const { identity_public_key: recipientPub } = + await apiService.getUserPublicKey(recipientEmail); + const envelope = await wrapProfileDekToRecipient( + profileDek, + recipientPub, + myIdentityPrivate, + ); + await apiService.createProfileShare(profileId, { + recipient_email: recipientEmail, + ephemeral_public_key: envelope.ephemeralPublicKey, + wrapped_profile_dek: envelope.wrappedProfileDek.data, + wrapped_profile_dek_iv: envelope.wrappedProfileDek.iv, + permissions: ['read'], + }); + // Refresh the share listing for this profile. + await get().loadProfileShares(profileId); + set({ isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to share profile', + isLoading: false, + }); + throw error; + } + }, + + revokeShare: async (profileId, recipientUserId) => { + set({ isLoading: true, error: null }); + try { + await apiService.deleteProfileShare(profileId, recipientUserId); + await get().loadProfileShares(profileId); + set({ isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to revoke share', + isLoading: false, + }); + throw error; + } + }, + + loadProfileShares: async (profileId) => { + try { + const shares = await apiService.listProfileShares(profileId); + set((state) => ({ + profileShares: { ...state.profileShares, [profileId]: shares }, + })); + } catch (error: any) { + set({ error: error.message || 'Failed to load shares' }); + } + }, + clearError: () => set({ error: null }), })), ); diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 9411408..6a719b2 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -371,6 +371,49 @@ export interface UpdateProfileRequest { relationship?: string; } +// A profile shared TO the current user (recipient view). Carries the +// per-share ECDH-wrapped DEK the recipient unwraps with their identity +// private key (not the account-wrapped form owned profiles use). +export interface SharedProfileWireResponse { + profile_id: string; + owner_account_id: string; + name_data: string; + name_iv: string; + kind: string; + relationship: string; + ephemeral_public_key: string; + wrapped_profile_dek: string; + wrapped_profile_dek_iv: string; + permissions: string[]; + created_at: string; +} + +// Request body for POST /profiles/:id/shares (owner wraps client-side). +export interface CreateProfileShareRequest { + recipient_email: string; + ephemeral_public_key: string; + wrapped_profile_dek: string; + wrapped_profile_dek_iv: string; + permissions?: string[]; + expires_at?: string; +} + +// A share the owner has created (listing view). +export interface ProfileShareListing { + profile_id: string; + recipient_user_id: string; + recipient_email: string; + permissions: string[]; + created_at: string; + active: boolean; +} + +// Response from GET /users/public-key?email=... +export interface PublicKeyResponse { + user_id: string; + identity_public_key: string; +} + // Dose Log Types — match backend MedicationDose (camelCase serialization). export interface DoseLog { id?: string; @@ -411,6 +454,10 @@ export interface Profile { permissions: string[]; created_at: string; updated_at: string; + /** True if this profile is shared TO the current user (recipient view). + * Undefined/false for profiles the user owns. Set by the store when merging + * shared-with-me results. */ + is_shared?: boolean; } // Error Types