feat: profile sharing via X25519 envelope (Phase B, #3) #11

Merged
alvaro merged 3 commits from feat/3-phase-b-profile-sharing into main 2026-07-19 14:51:58 +00:00
3 changed files with 74 additions and 22 deletions
Showing only changes of commit d0efecf38f - Show all commits

View file

@ -69,13 +69,23 @@ pub async fn list_appointments(
let database = state.db.get_database();
let repo = AppointmentRepository::new(database.collection("appointments"));
// Share-gate: if a profile_id is specified, resolve the owner (admits the
// owner or an active share recipient) and query as them. Without a
// profile_id, return the caller's own data across all their profiles.
// 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(),
};
@ -121,9 +131,12 @@ pub async fn get_appointment(
));
}
};
// Share-gate on the appointment's profile.
// 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()))
}

View file

@ -90,16 +90,28 @@ pub async fn list_health_stats(
.into_response()
}
};
// Share-gate: if a profile_id is specified, resolve the owner (admits the
// owner or an active share recipient) and query as them.
// 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) => {
match crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await
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
@ -146,7 +158,11 @@ pub async fn get_health_stat(
match repo.find_by_id(&object_id).await {
Ok(Some(stat)) => {
// Share-gate on the stat's profile (admits owner or recipient).
// Authorization: direct owner (user_id == caller) OR share-gate on
// the stat's profile (admits an active share recipient).
if stat.user_id == claims.sub {
return (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response();
}
match crate::handlers::profile_share::authorize_profile_read(
&state,
&claims,

View file

@ -68,15 +68,28 @@ pub async fn list_medications(
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// Resolve the effective owner. If the caller specifies a profile_id, run
// the share-gate — it admits the owner or an active share recipient, and
// returns the profile's owner userId to query as. Without a profile_id,
// fall back to the caller's own data across all their profiles (recipients
// must always specify profile_id to read shared data).
// 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(),
};
@ -124,9 +137,19 @@ pub async fn get_medication(
));
}
};
// Share-gate on the medication's profile — admits owner or recipient.
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &medication.profile_id)
// 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()))
}