diff --git a/backend/src/app.rs b/backend/src/app.rs index 00e7667..27c39b0 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -35,10 +35,7 @@ pub fn build_app(state: AppState) -> Router { "/api/auth/recover-password", post(handlers::recover_password), ) - .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)); + .route("/api/auth/recovery-info", get(handlers::recovery_info)); // Build protected routes (auth required) let protected_routes = Router::new() @@ -50,22 +47,19 @@ 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)) - // 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. + // 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) .route("/api/profiles", get(handlers::list_profiles)) .route("/api/profiles", post(handlers::create_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", get(handlers::get_profile)) .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 6cdd71f..8193095 100644 --- a/backend/src/db/init.rs +++ b/backend/src/db/init.rs @@ -98,30 +98,15 @@ impl DatabaseInitializer { println!("✓ Created appointments collection"); } - // 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). + // Create shares collection and index { - let collection: Collection = - self.db.collection("profile_shares"); + let collection: Collection = self.db.collection("shares"); - 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), - } + let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build(); - 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 - ), + 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), } } diff --git a/backend/src/db/mongodb_impl.rs b/backend/src/db/mongodb_impl.rs index 64952df..e1410ae 100644 --- a/backend/src/db/mongodb_impl.rs +++ b/backend/src/db/mongodb_impl.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::models::{ medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest}, + permission::Permission, + share::{Share, ShareRepository}, user::{User, UserRepository}, }; @@ -12,6 +14,7 @@ use crate::models::{ pub struct MongoDb { database: Database, pub users: Collection, + pub shares: Collection, pub medications: Collection, pub medication_doses: Collection, } @@ -59,6 +62,7 @@ impl MongoDb { return Ok(Self { users: database.collection("users"), + shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -93,6 +97,7 @@ impl MongoDb { return Ok(Self { users: database.collection("users"), + shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -108,6 +113,7 @@ impl MongoDb { Ok(Self { users: database.collection("users"), + shares: database.collection("shares"), medications: database.collection("medications"), medication_doses: database.collection("medication_doses"), database, @@ -171,6 +177,100 @@ 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 7939fc4..9b8d709 100644 --- a/backend/src/handlers/appointments.rs +++ b/backend/src/handlers/appointments.rs @@ -65,79 +65,39 @@ pub async fn list_appointments( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, (StatusCode, Json)> { +) -> Result>, StatusCode> { 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(&owner, query.status.as_deref(), query.profile_id.as_deref()) + .find_by_user_filtered( + &claims.sub, + 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(e) => { - tracing::error!("list_appointments failed: {}", e); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - )) - } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } pub async fn get_appointment( State(state): State, - Extension(claims): Extension, + Extension(_claims): Extension, Path(id): Path, -) -> Result, (StatusCode, Json)> { +) -> Result, StatusCode> { let database = state.db.get_database(); let repo = AppointmentRepository::new(database.collection("appointments")); - 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?; + 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), } - 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 5397d8e..aa62870 100644 --- a/backend/src/handlers/health_stats.rs +++ b/backend/src/handlers/health_stats.rs @@ -90,32 +90,8 @@ 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(&owner, query.profile_id.as_deref()) + .find_by_user_filtered(&claims.sub, query.profile_id.as_deref()) .await { Ok(stats) => { @@ -158,21 +134,10 @@ pub async fn get_health_stat( match repo.find_by_id(&object_id).await { Ok(Some(stat)) => { - // 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(), + if stat.user_id != claims.sub { + return (StatusCode::FORBIDDEN, "Access denied").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 2488c9a..0d70c0e 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -64,93 +64,38 @@ pub async fn list_medications( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, (StatusCode, Json)> { +) -> Result>, StatusCode> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // 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(), - }; - + // Honor the active filter (and optional profile_id) when provided. match repo - .find_by_user_filtered(&owner, query.active, query.profile_id.as_deref()) + .find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref()) .await { Ok(medications) => { let resp: Vec = medications.into_iter().map(Into::into).collect(); Ok(Json(resp)) } - Err(e) => { - tracing::error!("list_medications failed: {}", e); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - )) - } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } pub async fn get_medication( State(state): State, - Extension(claims): Extension, + Extension(_claims): Extension, Path(id): Path, -) -> Result, (StatusCode, Json)> { +) -> Result, StatusCode> { 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. - 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?; + 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), } - Ok(Json(medication.into())) } pub async fn update_medication( diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 665fe4e..e90ba39 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -4,9 +4,10 @@ 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 @@ -23,12 +24,10 @@ 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 new file mode 100644 index 0000000..6a4c751 --- /dev/null +++ b/backend/src/handlers/permissions.rs @@ -0,0 +1,62 @@ +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 deleted file mode 100644 index d29e833..0000000 --- a/backend/src/handlers/profile_share.rs +++ /dev/null @@ -1,513 +0,0 @@ -//! 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 new file mode 100644 index 0000000..4481f96 --- /dev/null +++ b/backend/src/handlers/shares.rs @@ -0,0 +1,453 @@ +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 new file mode 100644 index 0000000..d265054 --- /dev/null +++ b/backend/src/middleware/permission.rs @@ -0,0 +1,96 @@ +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 175d86d..bc326e8 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -6,8 +6,9 @@ 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 new file mode 100644 index 0000000..db0513c --- /dev/null +++ b/backend/src/models/permission.rs @@ -0,0 +1,44 @@ +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 deleted file mode 100644 index f3929c7..0000000 --- a/backend/src/models/profile_share.rs +++ /dev/null @@ -1,179 +0,0 @@ -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 new file mode 100644 index 0000000..2a9df80 --- /dev/null +++ b/backend/src/models/share.rs @@ -0,0 +1,123 @@ +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 deleted file mode 100644 index 05d84a1..0000000 --- a/backend/tests/share_tests.rs +++ /dev/null @@ -1,456 +0,0 @@ -//! 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 d070990..8876081 100644 --- a/web/normogen-web/src/components/profile/ProfileEditor.tsx +++ b/web/normogen-web/src/components/profile/ProfileEditor.tsx @@ -16,7 +16,6 @@ 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 { @@ -151,11 +150,7 @@ export const ProfileEditor: FC = () => { ) : ( {active.name || user?.username || '—'} - {active.is_shared ? ( - - ) : ( - - )} + )} @@ -203,44 +198,25 @@ export const ProfileEditor: FC = () => { )} - {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} - - - - - - )} + + 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 deleted file mode 100644 index 12214fe..0000000 --- a/web/normogen-web/src/components/profile/ProfileSharing.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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 fa1c00a..08ec1e2 100644 --- a/web/normogen-web/src/components/profile/ProfileSwitcher.tsx +++ b/web/normogen-web/src/components/profile/ProfileSwitcher.tsx @@ -1,23 +1,17 @@ import { useEffect, type FC } from 'react'; -import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material'; +import { Box, 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` - * + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */ + * on mount so the list + per-profile DEKs are ready. */ export const ProfileSwitcher: FC = () => { - const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } = + const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } = useProfileStore(); useEffect(() => { - (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]); + loadProfiles(); + }, [loadProfiles]); if (profiles.length === 0) { return isLoading ? ( @@ -42,21 +36,10 @@ 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.is_shared && ( - - )} + {p.name || p.relationship || p.profile_id} ))} diff --git a/web/normogen-web/src/crypto/crypto.e2e.test.ts b/web/normogen-web/src/crypto/crypto.e2e.test.ts index 4430b44..5ba47a0 100644 --- a/web/normogen-web/src/crypto/crypto.e2e.test.ts +++ b/web/normogen-web/src/crypto/crypto.e2e.test.ts @@ -20,8 +20,6 @@ import { generateProfileDek, wrapProfileDek, unwrapProfileDek, - wrapProfileDekToRecipient, - unwrapProfileDekFromShare, setProfileDek, getProfileDek, clearProfileDeks, @@ -284,52 +282,3 @@ 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 798cec2..7f12d62 100644 --- a/web/normogen-web/src/crypto/index.ts +++ b/web/normogen-web/src/crypto/index.ts @@ -14,9 +14,6 @@ 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 b96d09d..c946be3 100644 --- a/web/normogen-web/src/crypto/keys.ts +++ b/web/normogen-web/src/crypto/keys.ts @@ -147,97 +147,6 @@ 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 753fab8..df31074 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -20,10 +20,6 @@ import { ProfileWireResponse, CreateProfileRequest, UpdateProfileRequest, - SharedProfileWireResponse, - CreateProfileShareRequest, - ProfileShareListing, - PublicKeyResponse, HealthStatWireResponse, UpdateHealthStatRequest, EncryptedFieldWire, @@ -264,50 +260,6 @@ 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 a7692b1..a88b309 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -12,8 +12,6 @@ import { generateProfileDek, wrapProfileDek, unwrapProfileDek, - wrapProfileDekToRecipient, - unwrapProfileDekFromShare, encrypt as encryptRaw, decrypt as decryptRaw, setEncKey, @@ -21,7 +19,6 @@ import { clearIdentityPrivate, clearProfileDeks, setIdentityPrivate, - getIdentityPrivate, getEncKey, getActiveProfileDek, getActiveProfileId, @@ -40,7 +37,6 @@ import { DrugInteraction, AdherenceStats, Profile, - ProfileShareListing, Appointment, CreateAppointmentRequest, UpdateAppointmentRequest, @@ -141,18 +137,6 @@ 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; } @@ -821,7 +805,6 @@ export const useProfileStore = create()( activeProfileId: null, isLoading: false, error: null, - profileShares: {}, loadProfiles: async () => { const accountDek = getEncKey(); @@ -997,130 +980,6 @@ 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 6a719b2..9411408 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -371,49 +371,6 @@ 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; @@ -454,10 +411,6 @@ 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