Compare commits
4 commits
015a99a7fe
...
d59487fa3b
| Author | SHA1 | Date | |
|---|---|---|---|
| d59487fa3b | |||
|
|
d0efecf38f | ||
|
|
e28135bc08 | ||
|
|
04520539aa |
25 changed files with 1934 additions and 951 deletions
|
|
@ -35,7 +35,10 @@ pub fn build_app(state: AppState) -> Router {
|
||||||
"/api/auth/recover-password",
|
"/api/auth/recover-password",
|
||||||
post(handlers::recover_password),
|
post(handlers::recover_password),
|
||||||
)
|
)
|
||||||
.route("/api/auth/recovery-info", get(handlers::recovery_info));
|
.route("/api/auth/recovery-info", get(handlers::recovery_info))
|
||||||
|
// Recipient identity public key lookup (Phase B). Public keys are not
|
||||||
|
// secret, so this is unauthenticated — mirrors recovery-info.
|
||||||
|
.route("/api/users/public-key", get(handlers::get_public_key));
|
||||||
|
|
||||||
// Build protected routes (auth required)
|
// Build protected routes (auth required)
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
|
|
@ -47,19 +50,22 @@ pub fn build_app(state: AppState) -> Router {
|
||||||
// User settings
|
// User settings
|
||||||
.route("/api/users/me/settings", get(handlers::get_settings))
|
.route("/api/users/me/settings", get(handlers::get_settings))
|
||||||
.route("/api/users/me/settings", put(handlers::update_settings))
|
.route("/api/users/me/settings", put(handlers::update_settings))
|
||||||
// Share management
|
// Profile management (Phase A2 multi-profile + Phase B sharing).
|
||||||
.route("/api/shares", post(handlers::create_share))
|
// GET /api/profiles/:id is share-aware (admits recipients); the other
|
||||||
.route("/api/shares", get(handlers::list_shares))
|
// profile ops remain owner-only.
|
||||||
.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", get(handlers::list_profiles))
|
||||||
.route("/api/profiles", post(handlers::create_profile))
|
.route("/api/profiles", post(handlers::create_profile))
|
||||||
.route("/api/profiles/:id", get(handlers::get_profile))
|
.route("/api/profiles/shared-with-me", get(handlers::list_shared_with_me))
|
||||||
|
.route("/api/profiles/:id", get(handlers::get_profile_shared_aware))
|
||||||
.route("/api/profiles/:id", put(handlers::update_profile))
|
.route("/api/profiles/:id", put(handlers::update_profile))
|
||||||
.route("/api/profiles/:id", delete(handlers::delete_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)
|
// Session management (Phase 2.6)
|
||||||
.route("/api/sessions", get(handlers::get_sessions))
|
.route("/api/sessions", get(handlers::get_sessions))
|
||||||
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,30 @@ impl DatabaseInitializer {
|
||||||
println!("✓ Created appointments collection");
|
println!("✓ Created appointments collection");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create shares collection and index
|
// Create profile_shares collection and indexes (Phase B — replaces the
|
||||||
|
// legacy shares collection). Lookups are by (profileId, recipientUserId)
|
||||||
|
// and by recipientUserId alone (for /profiles/shared-with-me).
|
||||||
{
|
{
|
||||||
let collection: Collection<mongodb::bson::Document> = self.db.collection("shares");
|
let collection: Collection<mongodb::bson::Document> =
|
||||||
|
self.db.collection("profile_shares");
|
||||||
|
|
||||||
let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
|
let pair_index = IndexModel::builder()
|
||||||
|
.keys(doc! { "profileId": 1, "recipientUserId": 1 })
|
||||||
|
.build();
|
||||||
|
match collection.create_index(pair_index, None).await {
|
||||||
|
Ok(_) => println!("✓ Created index on profile_shares (profileId, recipientUserId)"),
|
||||||
|
Err(e) => println!("Warning: Failed to create profile_shares pair index: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
match collection.create_index(index, None).await {
|
let recipient_index = IndexModel::builder()
|
||||||
Ok(_) => println!("✓ Created index on shares.familyId"),
|
.keys(doc! { "recipientUserId": 1 })
|
||||||
Err(e) => println!("Warning: Failed to create index on shares.familyId: {}", e),
|
.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
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ use std::time::Duration;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
|
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
|
||||||
permission::Permission,
|
|
||||||
share::{Share, ShareRepository},
|
|
||||||
user::{User, UserRepository},
|
user::{User, UserRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -14,7 +12,6 @@ use crate::models::{
|
||||||
pub struct MongoDb {
|
pub struct MongoDb {
|
||||||
database: Database,
|
database: Database,
|
||||||
pub users: Collection<User>,
|
pub users: Collection<User>,
|
||||||
pub shares: Collection<Share>,
|
|
||||||
pub medications: Collection<Medication>,
|
pub medications: Collection<Medication>,
|
||||||
pub medication_doses: Collection<MedicationDose>,
|
pub medication_doses: Collection<MedicationDose>,
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +59,6 @@ impl MongoDb {
|
||||||
|
|
||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
users: database.collection("users"),
|
users: database.collection("users"),
|
||||||
shares: database.collection("shares"),
|
|
||||||
medications: database.collection("medications"),
|
medications: database.collection("medications"),
|
||||||
medication_doses: database.collection("medication_doses"),
|
medication_doses: database.collection("medication_doses"),
|
||||||
database,
|
database,
|
||||||
|
|
@ -97,7 +93,6 @@ impl MongoDb {
|
||||||
|
|
||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
users: database.collection("users"),
|
users: database.collection("users"),
|
||||||
shares: database.collection("shares"),
|
|
||||||
medications: database.collection("medications"),
|
medications: database.collection("medications"),
|
||||||
medication_doses: database.collection("medication_doses"),
|
medication_doses: database.collection("medication_doses"),
|
||||||
database,
|
database,
|
||||||
|
|
@ -113,7 +108,6 @@ impl MongoDb {
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
users: database.collection("users"),
|
users: database.collection("users"),
|
||||||
shares: database.collection("shares"),
|
|
||||||
medications: database.collection("medications"),
|
medications: database.collection("medications"),
|
||||||
medication_doses: database.collection("medication_doses"),
|
medication_doses: database.collection("medication_doses"),
|
||||||
database,
|
database,
|
||||||
|
|
@ -177,100 +171,6 @@ impl MongoDb {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Share Methods =====
|
|
||||||
|
|
||||||
pub async fn create_share(&self, share: &Share) -> Result<Option<ObjectId>> {
|
|
||||||
let repo = ShareRepository::new(self.shares.clone());
|
|
||||||
Ok(repo.create(share).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_share(&self, id: &str) -> Result<Option<Share>> {
|
|
||||||
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<Vec<Share>> {
|
|
||||||
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<bool> {
|
|
||||||
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<bool> {
|
|
||||||
// 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) =====
|
// ===== Medication Methods (Fixed for Phase 2.8) =====
|
||||||
|
|
||||||
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
|
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
|
||||||
|
|
|
||||||
|
|
@ -65,39 +65,79 @@ pub async fn list_appointments(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Query(query): Query<AppointmentListQuery>,
|
Query(query): Query<AppointmentListQuery>,
|
||||||
) -> Result<Json<Vec<AppointmentResponse>>, StatusCode> {
|
) -> Result<Json<Vec<AppointmentResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
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
|
match repo
|
||||||
.find_by_user_filtered(
|
.find_by_user_filtered(&owner, query.status.as_deref(), query.profile_id.as_deref())
|
||||||
&claims.sub,
|
|
||||||
query.status.as_deref(),
|
|
||||||
query.profile_id.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(appointments) => {
|
Ok(appointments) => {
|
||||||
let resp: Vec<AppointmentResponse> = appointments.into_iter().map(Into::into).collect();
|
let resp: Vec<AppointmentResponse> = appointments.into_iter().map(Into::into).collect();
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
}
|
}
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
Err(e) => {
|
||||||
|
tracing::error!("list_appointments failed: {}", e);
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_appointment(
|
pub async fn get_appointment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||||
|
|
||||||
match repo.find_by_appointment_id(&id).await {
|
let appt = match repo.find_by_appointment_id(&id).await {
|
||||||
Ok(Some(appt)) => Ok(Json(appt.into())),
|
Ok(Some(a)) => a,
|
||||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
Ok(None) => {
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({ "error": "not found" })),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("get_appointment failed: {}", e);
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Authorization: direct owner (user_id == caller) OR share-gate on the
|
||||||
|
// appointment's profile (admits an active share recipient).
|
||||||
|
if appt.user_id != claims.sub {
|
||||||
|
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(Json(appt.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_appointment(
|
pub async fn update_appointment(
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,32 @@ pub async fn list_health_stats(
|
||||||
.into_response()
|
.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
|
match repo
|
||||||
.find_by_user_filtered(&claims.sub, query.profile_id.as_deref())
|
.find_by_user_filtered(&owner, query.profile_id.as_deref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(stats) => {
|
Ok(stats) => {
|
||||||
|
|
@ -134,10 +158,21 @@ pub async fn get_health_stat(
|
||||||
|
|
||||||
match repo.find_by_id(&object_id).await {
|
match repo.find_by_id(&object_id).await {
|
||||||
Ok(Some(stat)) => {
|
Ok(Some(stat)) => {
|
||||||
if stat.user_id != claims.sub {
|
// Authorization: direct owner (user_id == caller) OR share-gate on
|
||||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
// the stat's profile (admits an active share recipient).
|
||||||
|
if stat.user_id == claims.sub {
|
||||||
|
return (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response();
|
||||||
|
}
|
||||||
|
match crate::handlers::profile_share::authorize_profile_read(
|
||||||
|
&state,
|
||||||
|
&claims,
|
||||||
|
&stat.profile_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(),
|
||||||
|
Err(resp) => resp.into_response(),
|
||||||
}
|
}
|
||||||
(StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response()
|
|
||||||
}
|
}
|
||||||
Ok(None) => (
|
Ok(None) => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|
|
||||||
|
|
@ -64,38 +64,93 @@ pub async fn list_medications(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Query(query): Query<ListMedicationsQuery>,
|
Query(query): Query<ListMedicationsQuery>,
|
||||||
) -> Result<Json<Vec<MedicationResponse>>, StatusCode> {
|
) -> Result<Json<Vec<MedicationResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = MedicationRepository::new(database.collection("medications"));
|
let repo = MedicationRepository::new(database.collection("medications"));
|
||||||
|
|
||||||
// Honor the active filter (and optional profile_id) when provided.
|
// Resolve the effective owner. If the caller specifies a profile_id, prefer
|
||||||
|
// direct ownership (the caller's own data for that profile); if they don't
|
||||||
|
// own it, fall through to the share-gate (admits an active share recipient
|
||||||
|
// and returns the profile's owner userId to query as). Without a profile_id,
|
||||||
|
// return the caller's own data across all their profiles.
|
||||||
|
//
|
||||||
|
// The direct-ownership-first check matters because a medication's profile_id
|
||||||
|
// is a free-form string at create time and may not map to a Profile document
|
||||||
|
// (legacy/test data) — the gate would 404 on those even for the real owner.
|
||||||
|
let owner = match &query.profile_id {
|
||||||
|
Some(pid) => {
|
||||||
|
let has_own = repo
|
||||||
|
.find_by_user_and_profile(&claims.sub, pid)
|
||||||
|
.await
|
||||||
|
.map(|v| !v.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_own {
|
||||||
|
claims.sub.clone()
|
||||||
|
} else {
|
||||||
|
crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => claims.sub.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
match repo
|
match repo
|
||||||
.find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref())
|
.find_by_user_filtered(&owner, query.active, query.profile_id.as_deref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(medications) => {
|
Ok(medications) => {
|
||||||
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
|
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
}
|
}
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
Err(e) => {
|
||||||
|
tracing::error!("list_medications failed: {}", e);
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_medication(
|
pub async fn get_medication(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<MedicationResponse>, StatusCode> {
|
) -> Result<Json<MedicationResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = MedicationRepository::new(database.collection("medications"));
|
let repo = MedicationRepository::new(database.collection("medications"));
|
||||||
|
|
||||||
// The path param is the application-level medication_id (a UUID), not the
|
// 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.
|
// Mongo _id, so look it up directly instead of parsing as ObjectId.
|
||||||
match repo.find_by_medication_id(&id).await {
|
let medication = match repo.find_by_medication_id(&id).await {
|
||||||
Ok(Some(medication)) => Ok(Json(medication.into())),
|
Ok(Some(m)) => m,
|
||||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
Ok(None) => {
|
||||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({ "error": "not found" })),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("get_medication failed: {}", e);
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Authorization: admit the direct owner (user_id == caller) OR, if not,
|
||||||
|
// fall through to the share-gate on the medication's profile (admits an
|
||||||
|
// active share recipient). The direct-owner check preserves the original
|
||||||
|
// ownership model for medications whose profile_id may not map to a real
|
||||||
|
// Profile document (e.g. legacy/test data).
|
||||||
|
if medication.user_id != claims.sub {
|
||||||
|
crate::handlers::profile_share::authorize_profile_read(
|
||||||
|
&state,
|
||||||
|
&claims,
|
||||||
|
&medication.profile_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(Json(medication.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_medication(
|
pub async fn update_medication(
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ pub mod health;
|
||||||
pub mod health_stats;
|
pub mod health_stats;
|
||||||
pub mod interactions;
|
pub mod interactions;
|
||||||
pub mod medications;
|
pub mod medications;
|
||||||
pub mod permissions;
|
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod profile_share;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod shares;
|
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
// Re-export commonly used handler functions
|
// Re-export commonly used handler functions
|
||||||
|
|
@ -24,10 +23,12 @@ pub use medications::{
|
||||||
create_medication, delete_medication, get_adherence, get_medication, list_medications,
|
create_medication, delete_medication, get_adherence, get_medication, list_medications,
|
||||||
log_dose, update_medication,
|
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::{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 sessions::{get_sessions, revoke_all_sessions, revoke_session};
|
||||||
pub use shares::{create_share, delete_share, list_shares, update_share};
|
|
||||||
pub use users::{
|
pub use users::{
|
||||||
change_password, delete_account, get_account, get_settings, update_account, update_settings,
|
change_password, delete_account, get_account, get_settings, update_account, update_settings,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
use axum::{
|
|
||||||
extract::{Query, State},
|
|
||||||
http::StatusCode,
|
|
||||||
response::IntoResponse,
|
|
||||||
Extension, Json,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{auth::jwt::Claims, config::AppState};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct CheckPermissionQuery {
|
|
||||||
pub resource_type: String,
|
|
||||||
pub resource_id: String,
|
|
||||||
pub permission: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct PermissionCheckResponse {
|
|
||||||
pub has_permission: bool,
|
|
||||||
pub resource_type: String,
|
|
||||||
pub resource_id: String,
|
|
||||||
pub permission: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn check_permission(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(params): Query<CheckPermissionQuery>,
|
|
||||||
Extension(claims): Extension<Claims>,
|
|
||||||
) -> 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()
|
|
||||||
}
|
|
||||||
513
backend/src/handlers/profile_share.rs
Normal file
513
backend/src/handlers/profile_share.rs
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
//! Profile sharing (Phase B).
|
||||||
|
//!
|
||||||
|
//! Implements the X25519 envelope: an owner wraps a profile DEK to a
|
||||||
|
//! recipient's identity public key via ECDH, storing the opaque wrapped blob
|
||||||
|
//! keyed by (profileId, recipientUserId). Recipients unwrap with their own
|
||||||
|
//! identity private key. The server never sees the profile DEK.
|
||||||
|
//!
|
||||||
|
//! Also provides `authorize_profile_read` — the share-gate used by the data
|
||||||
|
//! handlers (medications, appointments, health stats) to admit recipients.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use mongodb::bson::{oid::ObjectId, DateTime};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::jwt::Claims,
|
||||||
|
config::AppState,
|
||||||
|
models::profile::ProfileRepository,
|
||||||
|
models::profile_share::{ProfileShare, ProfileShareRepository},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn profile_repo(state: &AppState) -> ProfileRepository {
|
||||||
|
ProfileRepository::new(state.db.get_database().collection("profiles"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_repo(state: &AppState) -> ProfileShareRepository {
|
||||||
|
ProfileShareRepository::new(state.db.get_database().collection("profile_shares"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared profile as seen by the recipient — the profile's metadata
|
||||||
|
/// (display name blob, kind, relationship) plus the per-share ECDH-wrapped DEK
|
||||||
|
/// the recipient unwraps with their identity private key. The server returns
|
||||||
|
/// the wrapped DEK from the share record verbatim and cannot read it.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SharedProfileResponse {
|
||||||
|
pub profile_id: String,
|
||||||
|
pub owner_account_id: String,
|
||||||
|
pub name_data: String,
|
||||||
|
pub name_iv: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub relationship: String,
|
||||||
|
/// Per-share ephemeral X25519 public key (base64 raw). The recipient
|
||||||
|
/// combines it with their identity private key to derive the wrap key.
|
||||||
|
pub ephemeral_public_key: String,
|
||||||
|
/// Profile DEK wrapped under the ECDH-derived key (opaque).
|
||||||
|
pub wrapped_profile_dek: String,
|
||||||
|
pub wrapped_profile_dek_iv: String,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
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<String>,
|
||||||
|
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<String, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
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<String>,
|
||||||
|
/// Optional RFC-3339 expiry; null/absent = never expires.
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_read_permissions() -> Vec<String> {
|
||||||
|
vec!["read".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_share(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Path(profile_id): Path<String>,
|
||||||
|
Json(req): Json<CreateShareRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<ShareListingResponse>), (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
// 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<AppState>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Path(profile_id): Path<String>,
|
||||||
|
) -> Result<Json<Vec<ShareListingResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
// 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<AppState>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Path((profile_id, recipient_user_id)): Path<(String, String)>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
// 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<AppState>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
) -> Result<Json<Vec<SharedProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
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<AppState>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Path(profile_id): Path<String>,
|
||||||
|
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
// 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<AppState>,
|
||||||
|
Query(q): Query<PublicKeyQuery>,
|
||||||
|
) -> Result<Json<PublicKeyResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -1,453 +0,0 @@
|
||||||
use axum::{
|
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
response::IntoResponse,
|
|
||||||
Extension, Json,
|
|
||||||
};
|
|
||||||
use mongodb::bson::oid::ObjectId;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use validator::Validate;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::jwt::Claims,
|
|
||||||
config::AppState,
|
|
||||||
models::{permission::Permission, share::Share},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
|
||||||
pub struct CreateShareRequest {
|
|
||||||
pub target_user_email: String,
|
|
||||||
pub resource_type: String,
|
|
||||||
pub resource_id: Option<String>,
|
|
||||||
pub permissions: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub expires_days: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ShareResponse {
|
|
||||||
pub id: String,
|
|
||||||
pub target_user_id: String,
|
|
||||||
pub resource_type: String,
|
|
||||||
pub resource_id: Option<String>,
|
|
||||||
pub permissions: Vec<String>,
|
|
||||||
pub expires_at: Option<i64>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<Share> for ShareResponse {
|
|
||||||
type Error = anyhow::Error;
|
|
||||||
|
|
||||||
fn try_from(share: Share) -> Result<Self, Self::Error> {
|
|
||||||
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<AppState>,
|
|
||||||
Extension(claims): Extension<Claims>,
|
|
||||||
Json(req): Json<CreateShareRequest>,
|
|
||||||
) -> 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<Permission> = 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<AppState>,
|
|
||||||
Extension(claims): Extension<Claims>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let user_id = &claims.sub;
|
|
||||||
|
|
||||||
match state.db.list_shares_for_user(user_id).await {
|
|
||||||
Ok(shares) => {
|
|
||||||
let responses: Vec<ShareResponse> = 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<AppState>, Path(id): Path<String>) -> 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<Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub active: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub expires_days: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_share(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
Extension(claims): Extension<Claims>,
|
|
||||||
Json(req): Json<UpdateShareRequest>,
|
|
||||||
) -> 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<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
Extension(claims): Extension<Claims>,
|
|
||||||
) -> 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
use axum::{
|
|
||||||
extract::{Request, State},
|
|
||||||
http::StatusCode,
|
|
||||||
middleware::Next,
|
|
||||||
response::Response,
|
|
||||||
};
|
|
||||||
use crate::config::AppState;
|
|
||||||
use crate::auth::Claims;
|
|
||||||
|
|
||||||
/// Middleware to check if user has permission for a resource
|
|
||||||
///
|
|
||||||
/// This middleware checks JWT claims (attached by auth middleware)
|
|
||||||
/// and verifies the user has the required permission level.
|
|
||||||
///
|
|
||||||
/// # Permission Levels
|
|
||||||
/// - "read": Can view resource
|
|
||||||
/// - "write": Can modify resource
|
|
||||||
/// - "admin": Full control including deletion
|
|
||||||
pub async fn has_permission(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
required_permission: String,
|
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, StatusCode> {
|
|
||||||
// Extract user_id from JWT claims (attached by auth middleware)
|
|
||||||
let user_id = match request.extensions().get::<Claims>() {
|
|
||||||
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<String> {
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,9 +6,8 @@ pub mod health_stats;
|
||||||
pub mod interactions;
|
pub mod interactions;
|
||||||
pub mod lab_result;
|
pub mod lab_result;
|
||||||
pub mod medication;
|
pub mod medication;
|
||||||
pub mod permission;
|
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod profile_share;
|
||||||
pub mod refresh_token;
|
pub mod refresh_token;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod share;
|
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum Permission {
|
|
||||||
Read,
|
|
||||||
Write,
|
|
||||||
Delete,
|
|
||||||
Share,
|
|
||||||
Admin,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for Permission {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Read => write!(f, "read"),
|
|
||||||
Self::Write => write!(f, "write"),
|
|
||||||
Self::Delete => write!(f, "delete"),
|
|
||||||
Self::Share => write!(f, "share"),
|
|
||||||
Self::Admin => write!(f, "admin"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Permission {
|
|
||||||
pub fn can_read(&self) -> bool {
|
|
||||||
matches!(self, Self::Read | Self::Admin)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn can_write(&self) -> bool {
|
|
||||||
matches!(self, Self::Write | Self::Admin)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn can_delete(&self) -> bool {
|
|
||||||
matches!(self, Self::Delete | Self::Admin)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn can_share(&self) -> bool {
|
|
||||||
matches!(self, Self::Share | Self::Admin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Permissions = Vec<Permission>;
|
|
||||||
179
backend/src/models/profile_share.rs
Normal file
179
backend/src/models/profile_share.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
use mongodb::{
|
||||||
|
bson::{doc, oid::ObjectId, DateTime},
|
||||||
|
Collection,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A profile shared from one account (owner) to another (recipient).
|
||||||
|
///
|
||||||
|
/// Zero-knowledge envelope (Phase B, see `docs/adr/multi-person-sharing.md`):
|
||||||
|
/// the owner wraps the profile DEK to the recipient's X25519 identity public
|
||||||
|
/// key via ECDH, using a fresh ephemeral keypair per share. The server stores
|
||||||
|
/// only opaque ciphertext + public keys and cannot read the profile DEK.
|
||||||
|
///
|
||||||
|
/// `permissions` reserves `read` (Phase B), `write`, and `admin` for later
|
||||||
|
/// phases. Phase B only grants read access.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileShare {
|
||||||
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<ObjectId>,
|
||||||
|
#[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<String>,
|
||||||
|
/// 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<DateTime>,
|
||||||
|
#[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<String> {
|
||||||
|
vec!["read".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProfileShareRepository {
|
||||||
|
collection: Collection<ProfileShare>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileShareRepository {
|
||||||
|
pub fn new(collection: Collection<ProfileShare>) -> 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<Vec<ProfileShare>> {
|
||||||
|
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<Vec<ProfileShare>> {
|
||||||
|
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<Option<ProfileShare>> {
|
||||||
|
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<Option<ProfileShare>> {
|
||||||
|
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<bool> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
use mongodb::bson::DateTime;
|
|
||||||
use mongodb::bson::{doc, oid::ObjectId};
|
|
||||||
use mongodb::Collection;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use super::permission::Permission;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Share {
|
|
||||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub id: Option<ObjectId>,
|
|
||||||
pub owner_id: ObjectId,
|
|
||||||
pub target_user_id: ObjectId,
|
|
||||||
pub resource_type: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub resource_id: Option<ObjectId>,
|
|
||||||
pub permissions: Vec<Permission>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub expires_at: Option<DateTime>,
|
|
||||||
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<ObjectId>,
|
|
||||||
permissions: Vec<Permission>,
|
|
||||||
expires_at: Option<DateTime>,
|
|
||||||
) -> 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<Share>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShareRepository {
|
|
||||||
pub fn new(collection: Collection<Share>) -> Self {
|
|
||||||
Self { collection }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create(&self, share: &Share) -> mongodb::error::Result<Option<ObjectId>> {
|
|
||||||
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<Option<Share>> {
|
|
||||||
self.collection.find_one(doc! { "_id": id }, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn find_by_owner(&self, owner_id: &ObjectId) -> mongodb::error::Result<Vec<Share>> {
|
|
||||||
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<Vec<Share>> {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
456
backend/tests/share_tests.rs
Normal file
456
backend/tests/share_tests.rs
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
//! Profile-sharing integration tests (Phase B).
|
||||||
|
//!
|
||||||
|
//! Exercises the X25519 envelope endpoints and the share-gate: an owner
|
||||||
|
//! shares a profile to a recipient; the recipient sees it in
|
||||||
|
//! /profiles/shared-with-me and can read its data; revoke cuts off access.
|
||||||
|
//! All wrapped blobs are opaque strings — the server never inspects them, so
|
||||||
|
//! the test doesn't need real crypto.
|
||||||
|
//!
|
||||||
|
//! Requires a live MongoDB; skips gracefully otherwise.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
macro_rules! require_app {
|
||||||
|
($app:expr) => {
|
||||||
|
match $app {
|
||||||
|
Some(x) => x,
|
||||||
|
None => {
|
||||||
|
eprintln!("[integration] skipped (MongoDB unavailable)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_email() -> String {
|
||||||
|
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL-encode a value for a query string (emails contain '@').
|
||||||
|
fn q(email: &str) -> String {
|
||||||
|
email.replace('@', "%40")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a fully-set-up user (identity public key + a default self profile)
|
||||||
|
/// and return the access token. The owner profile is `profile_<user_id>`.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import SaveIcon from '@mui/icons-material/Save';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { useProfileStore, useAuthStore } from '../../store/useStore';
|
import { useProfileStore, useAuthStore } from '../../store/useStore';
|
||||||
|
import { ProfileSharing } from './ProfileSharing';
|
||||||
|
|
||||||
export const ProfileEditor: FC = () => {
|
export const ProfileEditor: FC = () => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -150,7 +151,11 @@ export const ProfileEditor: FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
|
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
|
||||||
|
{active.is_shared ? (
|
||||||
|
<Chip size="small" label="shared with you" color="info" variant="outlined" />
|
||||||
|
) : (
|
||||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -198,6 +203,16 @@ export const ProfileEditor: FC = () => {
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
{active.is_shared ? (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Owner</Typography>
|
||||||
|
<Typography variant="body2">{active.owner_account_id}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||||
|
You have read-only access to this shared profile.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary">Account</Typography>
|
<Typography variant="caption" color="text.secondary">Account</Typography>
|
||||||
<Typography variant="body2">{user?.email}</Typography>
|
<Typography variant="body2">{user?.email}</Typography>
|
||||||
|
|
@ -217,6 +232,15 @@ export const ProfileEditor: FC = () => {
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Owner-only sharing UI. */}
|
||||||
|
{!editing && !active.is_shared && (
|
||||||
|
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
<ProfileSharing profileId={active.profile_id} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
140
web/normogen-web/src/components/profile/ProfileSharing.tsx
Normal file
140
web/normogen-web/src/components/profile/ProfileSharing.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { useEffect, useState, type FC } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||||
|
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
|
||||||
|
import { useProfileStore } from '../../store/useStore';
|
||||||
|
|
||||||
|
/** Owner-side sharing UI for a single owned profile: lists the recipients the
|
||||||
|
* profile is shared with (with revoke buttons) and an "add recipient by
|
||||||
|
* email" field. Shown only for profiles the current user owns. Recipients
|
||||||
|
* get the profile via /profiles/shared-with-me + the ECDH-wrapped DEK. */
|
||||||
|
export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
|
||||||
|
const {
|
||||||
|
profileShares,
|
||||||
|
loadProfileShares,
|
||||||
|
shareProfile,
|
||||||
|
revokeShare,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
} = useProfileStore();
|
||||||
|
|
||||||
|
const [recipientEmail, setRecipientEmail] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfileShares(profileId);
|
||||||
|
}, [profileId, loadProfileShares]);
|
||||||
|
|
||||||
|
const shares = profileShares[profileId] ?? [];
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setLocalError('');
|
||||||
|
try {
|
||||||
|
await shareProfile(profileId, recipientEmail.trim());
|
||||||
|
setRecipientEmail('');
|
||||||
|
} catch (e: any) {
|
||||||
|
setLocalError(e?.message || 'Failed to share');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (recipientUserId: string) => {
|
||||||
|
if (!confirm('Revoke this share? The recipient will lose access immediately.')) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await revokeShare(profileId, recipientUserId);
|
||||||
|
} catch {
|
||||||
|
/* surfaced via store error */
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||||
|
Shared with
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{(error || localError) && (
|
||||||
|
<Alert
|
||||||
|
severity="error"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
onClose={() => {
|
||||||
|
setLocalError('');
|
||||||
|
clearError();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{localError || error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Not shared with anyone.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List dense sx={{ mb: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
|
||||||
|
{shares.map((s) => (
|
||||||
|
<ListItem key={s.recipient_user_id}>
|
||||||
|
<ListItemText
|
||||||
|
primary={s.recipient_email || s.recipient_user_id}
|
||||||
|
secondary={s.permissions.join(', ')}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
aria-label="revoke"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => handleRevoke(s.recipient_user_id)}
|
||||||
|
>
|
||||||
|
<PersonRemoveIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
type="email"
|
||||||
|
placeholder="recipient@example.com"
|
||||||
|
value={recipientEmail}
|
||||||
|
onChange={(e) => setRecipientEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PersonAddIcon />}
|
||||||
|
disabled={busy || !recipientEmail.trim()}
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
{busy ? <CircularProgress size={24} /> : 'Share'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||||
|
The recipient must have a Normogen account. They'll see this profile in their switcher.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileSharing;
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
import { useEffect, type FC } from 'react';
|
import { useEffect, type FC } from 'react';
|
||||||
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||||
import { useProfileStore } from '../../store/useStore';
|
import { useProfileStore } from '../../store/useStore';
|
||||||
|
|
||||||
/** A compact dropdown for switching the active profile (the subject of care
|
/** A compact dropdown for switching the active profile (the subject of care
|
||||||
* whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles`
|
* whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles`
|
||||||
* on mount so the list + per-profile DEKs are ready. */
|
* + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */
|
||||||
export const ProfileSwitcher: FC = () => {
|
export const ProfileSwitcher: FC = () => {
|
||||||
const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } =
|
const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } =
|
||||||
useProfileStore();
|
useProfileStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProfiles();
|
(async () => {
|
||||||
}, [loadProfiles]);
|
await loadProfiles();
|
||||||
|
// Fetch profiles shared TO me after owned profiles are loaded (the
|
||||||
|
// identity private key must be unwrapped first; loadSharedWithMe is a
|
||||||
|
// no-op if it isn't available yet).
|
||||||
|
await loadSharedWithMe();
|
||||||
|
})();
|
||||||
|
}, [loadProfiles, loadSharedWithMe]);
|
||||||
|
|
||||||
if (profiles.length === 0) {
|
if (profiles.length === 0) {
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
|
|
@ -36,10 +42,21 @@ export const ProfileSwitcher: FC = () => {
|
||||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||||
'.MuiSvgIcon-root': { color: 'common.white' },
|
'.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) => (
|
{profiles.map((p) => (
|
||||||
<MenuItem key={p.profile_id} value={p.profile_id}>
|
<MenuItem key={p.profile_id} value={p.profile_id}>
|
||||||
{p.name || p.relationship || p.profile_id}
|
<span>{p.name || p.relationship || p.profile_id}</span>
|
||||||
|
{p.is_shared && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="shared"
|
||||||
|
sx={{ ml: 1, height: 18, fontSize: '0.7rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import {
|
||||||
generateProfileDek,
|
generateProfileDek,
|
||||||
wrapProfileDek,
|
wrapProfileDek,
|
||||||
unwrapProfileDek,
|
unwrapProfileDek,
|
||||||
|
wrapProfileDekToRecipient,
|
||||||
|
unwrapProfileDekFromShare,
|
||||||
setProfileDek,
|
setProfileDek,
|
||||||
getProfileDek,
|
getProfileDek,
|
||||||
clearProfileDeks,
|
clearProfileDeks,
|
||||||
|
|
@ -282,3 +284,52 @@ describe('per-profile DEK isolation (Phase A2)', () => {
|
||||||
expect(getProfileDek('profile_child')).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ export {
|
||||||
generateProfileDek,
|
generateProfileDek,
|
||||||
wrapProfileDek,
|
wrapProfileDek,
|
||||||
unwrapProfileDek,
|
unwrapProfileDek,
|
||||||
|
wrapProfileDekToRecipient,
|
||||||
|
unwrapProfileDekFromShare,
|
||||||
|
type SharedProfileDekEnvelope,
|
||||||
setEncKey,
|
setEncKey,
|
||||||
getEncKey,
|
getEncKey,
|
||||||
clearEncKey,
|
clearEncKey,
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,97 @@ export async function unwrapProfileDek(
|
||||||
return unwrapDek(payload, accountDek);
|
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<CryptoKey> {
|
||||||
|
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<SharedProfileDekEnvelope> {
|
||||||
|
// 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<CryptoKey> {
|
||||||
|
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
|
// High-level flows
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ import {
|
||||||
ProfileWireResponse,
|
ProfileWireResponse,
|
||||||
CreateProfileRequest,
|
CreateProfileRequest,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
SharedProfileWireResponse,
|
||||||
|
CreateProfileShareRequest,
|
||||||
|
ProfileShareListing,
|
||||||
|
PublicKeyResponse,
|
||||||
HealthStatWireResponse,
|
HealthStatWireResponse,
|
||||||
UpdateHealthStatRequest,
|
UpdateHealthStatRequest,
|
||||||
EncryptedFieldWire,
|
EncryptedFieldWire,
|
||||||
|
|
@ -260,6 +264,50 @@ class ApiService {
|
||||||
await this.client.delete(`/profiles/${profileId}`);
|
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<PublicKeyResponse> {
|
||||||
|
const response = await this.client.get<PublicKeyResponse>('/users/public-key', {
|
||||||
|
params: { email },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Profiles shared TO the current user. Carries ECDH-wrapped profile DEKs. */
|
||||||
|
async listSharedWithMe(): Promise<SharedProfileWireResponse[]> {
|
||||||
|
const response = await this.client.get<SharedProfileWireResponse[]>(
|
||||||
|
'/profiles/shared-with-me',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner: list who a profile has been shared with. */
|
||||||
|
async listProfileShares(profileId: string): Promise<ProfileShareListing[]> {
|
||||||
|
const response = await this.client.get<ProfileShareListing[]>(
|
||||||
|
`/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<ProfileShareListing> {
|
||||||
|
const response = await this.client.post<ProfileShareListing>(
|
||||||
|
`/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<void> {
|
||||||
|
await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
||||||
|
|
||||||
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
generateProfileDek,
|
generateProfileDek,
|
||||||
wrapProfileDek,
|
wrapProfileDek,
|
||||||
unwrapProfileDek,
|
unwrapProfileDek,
|
||||||
|
wrapProfileDekToRecipient,
|
||||||
|
unwrapProfileDekFromShare,
|
||||||
encrypt as encryptRaw,
|
encrypt as encryptRaw,
|
||||||
decrypt as decryptRaw,
|
decrypt as decryptRaw,
|
||||||
setEncKey,
|
setEncKey,
|
||||||
|
|
@ -19,6 +21,7 @@ import {
|
||||||
clearIdentityPrivate,
|
clearIdentityPrivate,
|
||||||
clearProfileDeks,
|
clearProfileDeks,
|
||||||
setIdentityPrivate,
|
setIdentityPrivate,
|
||||||
|
getIdentityPrivate,
|
||||||
getEncKey,
|
getEncKey,
|
||||||
getActiveProfileDek,
|
getActiveProfileDek,
|
||||||
getActiveProfileId,
|
getActiveProfileId,
|
||||||
|
|
@ -37,6 +40,7 @@ import {
|
||||||
DrugInteraction,
|
DrugInteraction,
|
||||||
AdherenceStats,
|
AdherenceStats,
|
||||||
Profile,
|
Profile,
|
||||||
|
ProfileShareListing,
|
||||||
Appointment,
|
Appointment,
|
||||||
CreateAppointmentRequest,
|
CreateAppointmentRequest,
|
||||||
UpdateAppointmentRequest,
|
UpdateAppointmentRequest,
|
||||||
|
|
@ -137,6 +141,18 @@ interface ProfileState {
|
||||||
relationship?: string;
|
relationship?: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
deleteProfile: (profileId: string) => Promise<void>;
|
deleteProfile: (profileId: string) => Promise<void>;
|
||||||
|
/** 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<void>;
|
||||||
|
/** 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<void>;
|
||||||
|
/** Owner: revoke a share (delete the share record server-side). */
|
||||||
|
revokeShare: (profileId: string, recipientUserId: string) => Promise<void>;
|
||||||
|
/** Shares the current user has created for a profile (for the owner UI). */
|
||||||
|
profileShares: Record<string, ProfileShareListing[]>;
|
||||||
|
loadProfileShares: (profileId: string) => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -805,6 +821,7 @@ export const useProfileStore = create<ProfileState>()(
|
||||||
activeProfileId: null,
|
activeProfileId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
profileShares: {},
|
||||||
|
|
||||||
loadProfiles: async () => {
|
loadProfiles: async () => {
|
||||||
const accountDek = getEncKey();
|
const accountDek = getEncKey();
|
||||||
|
|
@ -980,6 +997,130 @@ export const useProfileStore = create<ProfileState>()(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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 }),
|
clearError: () => set({ error: null }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,49 @@ export interface UpdateProfileRequest {
|
||||||
relationship?: string;
|
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).
|
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||||
export interface DoseLog {
|
export interface DoseLog {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
@ -411,6 +454,10 @@ export interface Profile {
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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
|
// Error Types
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue