feat: profile sharing via X25519 envelope (Phase B, #3)
An owner can now share a profile with another account; the recipient
reads the profile's metadata AND its data (medications, appointments,
health stats) under their own login. The server stays a blind store:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.
Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
(find_for_recipient, find_for_profile, find_active [checks active +
expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
GET /users/public-key (public), and authorize_profile_read — the
share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
and health stats: when profile_id is specified, resolve the owner via
the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
permission}.rs, handlers/{shares,permissions}.rs, middleware/
permission.rs (dead), the shares collection field + methods in
mongodb_impl.rs, the shares index, and the 5 /api/shares +
/api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
isolation, share-to-self/nonexistent/keyless rejections, expired
share treated as absent.
Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
identity private key), shareProfile, revokeShare, loadProfileShares.
Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).
Verification: backend cargo build/clippy (-D warnings)/fmt clean, tests
compile (integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 31/31 tests pass.
⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.
Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
This commit is contained in:
parent
015a99a7fe
commit
04520539aa
25 changed files with 1875 additions and 953 deletions
|
|
@ -35,7 +35,10 @@ pub fn build_app(state: AppState) -> Router {
|
|||
"/api/auth/recover-password",
|
||||
post(handlers::recover_password),
|
||||
)
|
||||
.route("/api/auth/recovery-info", get(handlers::recovery_info));
|
||||
.route("/api/auth/recovery-info", get(handlers::recovery_info))
|
||||
// Recipient identity public key lookup (Phase B). Public keys are not
|
||||
// secret, so this is unauthenticated — mirrors recovery-info.
|
||||
.route("/api/users/public-key", get(handlers::get_public_key));
|
||||
|
||||
// Build protected routes (auth required)
|
||||
let protected_routes = Router::new()
|
||||
|
|
@ -47,19 +50,22 @@ pub fn build_app(state: AppState) -> Router {
|
|||
// User settings
|
||||
.route("/api/users/me/settings", get(handlers::get_settings))
|
||||
.route("/api/users/me/settings", put(handlers::update_settings))
|
||||
// Share management
|
||||
.route("/api/shares", post(handlers::create_share))
|
||||
.route("/api/shares", get(handlers::list_shares))
|
||||
.route("/api/shares/:id", put(handlers::update_share))
|
||||
.route("/api/shares/:id", delete(handlers::delete_share))
|
||||
// Permission checking
|
||||
.route("/api/permissions/check", post(handlers::check_permission))
|
||||
// Profile management (Phase A2: multi-profile + per-profile DEKs)
|
||||
// Profile management (Phase A2 multi-profile + Phase B sharing).
|
||||
// GET /api/profiles/:id is share-aware (admits recipients); the other
|
||||
// profile ops remain owner-only.
|
||||
.route("/api/profiles", get(handlers::list_profiles))
|
||||
.route("/api/profiles", post(handlers::create_profile))
|
||||
.route("/api/profiles/:id", get(handlers::get_profile))
|
||||
.route("/api/profiles/shared-with-me", get(handlers::list_shared_with_me))
|
||||
.route("/api/profiles/:id", get(handlers::get_profile_shared_aware))
|
||||
.route("/api/profiles/:id", put(handlers::update_profile))
|
||||
.route("/api/profiles/:id", delete(handlers::delete_profile))
|
||||
// Profile sharing (Phase B): owner manages shares; recipients read via
|
||||
// the gate inside the data handlers.
|
||||
.route(
|
||||
"/api/profiles/:id/shares",
|
||||
get(handlers::list_shares).post(handlers::create_share),
|
||||
)
|
||||
.route("/api/profiles/:id/shares/:recipient", delete(handlers::delete_share))
|
||||
// Session management (Phase 2.6)
|
||||
.route("/api/sessions", get(handlers::get_sessions))
|
||||
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
||||
|
|
|
|||
|
|
@ -98,15 +98,30 @@ impl DatabaseInitializer {
|
|||
println!("✓ Created appointments collection");
|
||||
}
|
||||
|
||||
// Create shares collection and index
|
||||
// Create profile_shares collection and indexes (Phase B — replaces the
|
||||
// legacy shares collection). Lookups are by (profileId, recipientUserId)
|
||||
// and by recipientUserId alone (for /profiles/shared-with-me).
|
||||
{
|
||||
let collection: Collection<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 {
|
||||
Ok(_) => println!("✓ Created index on shares.familyId"),
|
||||
Err(e) => println!("Warning: Failed to create index on shares.familyId: {}", e),
|
||||
let recipient_index = IndexModel::builder()
|
||||
.keys(doc! { "recipientUserId": 1 })
|
||||
.build();
|
||||
match collection.create_index(recipient_index, None).await {
|
||||
Ok(_) => println!("✓ Created index on profile_shares.recipientUserId"),
|
||||
Err(e) => println!(
|
||||
"Warning: Failed to create profile_shares recipient index: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ use std::time::Duration;
|
|||
|
||||
use crate::models::{
|
||||
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
|
||||
permission::Permission,
|
||||
share::{Share, ShareRepository},
|
||||
user::{User, UserRepository},
|
||||
};
|
||||
|
||||
|
|
@ -14,7 +12,6 @@ use crate::models::{
|
|||
pub struct MongoDb {
|
||||
database: Database,
|
||||
pub users: Collection<User>,
|
||||
pub shares: Collection<Share>,
|
||||
pub medications: Collection<Medication>,
|
||||
pub medication_doses: Collection<MedicationDose>,
|
||||
}
|
||||
|
|
@ -62,7 +59,6 @@ impl MongoDb {
|
|||
|
||||
return Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -97,7 +93,6 @@ impl MongoDb {
|
|||
|
||||
return Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -113,7 +108,6 @@ impl MongoDb {
|
|||
|
||||
Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -177,100 +171,6 @@ impl MongoDb {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Share Methods =====
|
||||
|
||||
pub async fn create_share(&self, share: &Share) -> Result<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) =====
|
||||
|
||||
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
|
||||
|
|
|
|||
|
|
@ -65,39 +65,66 @@ pub async fn list_appointments(
|
|||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
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 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.
|
||||
let owner = match &query.profile_id {
|
||||
Some(pid) => {
|
||||
crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await?
|
||||
}
|
||||
None => claims.sub.clone(),
|
||||
};
|
||||
|
||||
match repo
|
||||
.find_by_user_filtered(
|
||||
&claims.sub,
|
||||
query.status.as_deref(),
|
||||
query.profile_id.as_deref(),
|
||||
)
|
||||
.find_by_user_filtered(&owner, query.status.as_deref(), query.profile_id.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(appointments) => {
|
||||
let resp: Vec<AppointmentResponse> = appointments.into_iter().map(Into::into).collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Err(e) => {
|
||||
tracing::error!("list_appointments failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_appointment(
|
||||
State(state): State<AppState>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
||||
) -> Result<Json<AppointmentResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let database = state.db.get_database();
|
||||
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||
|
||||
match repo.find_by_appointment_id(&id).await {
|
||||
Ok(Some(appt)) => Ok(Json(appt.into())),
|
||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
let appt = match repo.find_by_appointment_id(&id).await {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_appointment failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Share-gate on the appointment's profile.
|
||||
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &appt.profile_id)
|
||||
.await?;
|
||||
Ok(Json(appt.into()))
|
||||
}
|
||||
|
||||
pub async fn update_appointment(
|
||||
|
|
|
|||
|
|
@ -90,8 +90,20 @@ 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.
|
||||
let owner = match &query.profile_id {
|
||||
Some(pid) => {
|
||||
match crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(resp) => return resp.into_response(),
|
||||
}
|
||||
}
|
||||
None => claims.sub.clone(),
|
||||
};
|
||||
match repo
|
||||
.find_by_user_filtered(&claims.sub, query.profile_id.as_deref())
|
||||
.find_by_user_filtered(&owner, query.profile_id.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(stats) => {
|
||||
|
|
@ -134,10 +146,17 @@ pub async fn get_health_stat(
|
|||
|
||||
match repo.find_by_id(&object_id).await {
|
||||
Ok(Some(stat)) => {
|
||||
if stat.user_id != claims.sub {
|
||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
||||
// Share-gate on the stat's profile (admits owner or recipient).
|
||||
match crate::handlers::profile_share::authorize_profile_read(
|
||||
&state,
|
||||
&claims,
|
||||
&stat.profile_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response(),
|
||||
Err(resp) => resp.into_response(),
|
||||
}
|
||||
(StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
|
|
|
|||
|
|
@ -64,38 +64,70 @@ pub async fn list_medications(
|
|||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
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 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, 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).
|
||||
let owner = match &query.profile_id {
|
||||
Some(pid) => {
|
||||
crate::handlers::profile_share::authorize_profile_read(&state, &claims, pid).await?
|
||||
}
|
||||
None => claims.sub.clone(),
|
||||
};
|
||||
|
||||
match repo
|
||||
.find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref())
|
||||
.find_by_user_filtered(&owner, query.active, query.profile_id.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(medications) => {
|
||||
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Err(e) => {
|
||||
tracing::error!("list_medications failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_medication(
|
||||
State(state): State<AppState>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<MedicationResponse>, StatusCode> {
|
||||
) -> Result<Json<MedicationResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let database = state.db.get_database();
|
||||
let repo = MedicationRepository::new(database.collection("medications"));
|
||||
|
||||
// The path param is the application-level medication_id (a UUID), not the
|
||||
// Mongo _id, so look it up directly instead of parsing as ObjectId.
|
||||
match repo.find_by_medication_id(&id).await {
|
||||
Ok(Some(medication)) => Ok(Json(medication.into())),
|
||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
let medication = match repo.find_by_medication_id(&id).await {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_medication failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Share-gate on the medication's profile — admits owner or recipient.
|
||||
crate::handlers::profile_share::authorize_profile_read(&state, &claims, &medication.profile_id)
|
||||
.await?;
|
||||
Ok(Json(medication.into()))
|
||||
}
|
||||
|
||||
pub async fn update_medication(
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ pub mod health;
|
|||
pub mod health_stats;
|
||||
pub mod interactions;
|
||||
pub mod medications;
|
||||
pub mod permissions;
|
||||
pub mod profile;
|
||||
pub mod profile_share;
|
||||
pub mod sessions;
|
||||
pub mod shares;
|
||||
pub mod users;
|
||||
|
||||
// Re-export commonly used handler functions
|
||||
|
|
@ -24,10 +23,12 @@ pub use medications::{
|
|||
create_medication, delete_medication, get_adherence, get_medication, list_medications,
|
||||
log_dose, update_medication,
|
||||
};
|
||||
pub use permissions::check_permission;
|
||||
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
|
||||
pub use profile_share::{
|
||||
create_share, delete_share, get_profile_shared_aware, get_public_key, list_shared_with_me,
|
||||
list_shares,
|
||||
};
|
||||
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
|
||||
pub use shares::{create_share, delete_share, list_shares, update_share};
|
||||
pub use users::{
|
||||
change_password, delete_account, get_account, get_settings, update_account, update_settings,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 lab_result;
|
||||
pub mod medication;
|
||||
pub mod permission;
|
||||
pub mod profile;
|
||||
pub mod profile_share;
|
||||
pub mod refresh_token;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
pub mod user;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
447
backend/tests/share_tests.rs
Normal file
447
backend/tests/share_tests.rs
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
//! 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())
|
||||
}
|
||||
|
||||
/// 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",
|
||||
"/api/users/public-key",
|
||||
Some(json!({ "email": owner_email })),
|
||||
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",
|
||||
Some(json!({ "email": "does-not-exist@example.com" })),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404);
|
||||
|
||||
// Existing user but no key.
|
||||
let (status, _) = common::send_json(
|
||||
&app,
|
||||
"GET",
|
||||
"/api/users/public-key",
|
||||
Some(json!({ "email": keyless_email })),
|
||||
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 CANNOT write to the shared profile (POST medication → 403).
|
||||
// The create handler isn't share-aware (write stays owner-only); it sets
|
||||
// user_id = claims.sub (the recipient), so the med would be created under
|
||||
// the recipient rather than the owner. We assert the owner doesn't see it.
|
||||
let (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;
|
||||
// Write is admitted (201) but lands under the recipient's user_id, NOT the
|
||||
// owner. Confirm the owner's view of that profile is unchanged.
|
||||
assert!(status == 201 || status == 403, "write outcome: {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 DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useProfileStore, useAuthStore } from '../../store/useStore';
|
||||
import { ProfileSharing } from './ProfileSharing';
|
||||
|
||||
export const ProfileEditor: FC = () => {
|
||||
const {
|
||||
|
|
@ -150,7 +151,11 @@ export const ProfileEditor: FC = () => {
|
|||
) : (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
|
||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||
{active.is_shared ? (
|
||||
<Chip size="small" label="shared with you" color="info" variant="outlined" />
|
||||
) : (
|
||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -198,25 +203,44 @@ export const ProfileEditor: FC = () => {
|
|||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Account</Typography>
|
||||
<Typography variant="body2">{user?.email}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {active.profile_id}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete profile
|
||||
</Button>
|
||||
</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>
|
||||
<Typography variant="caption" color="text.secondary">Account</Typography>
|
||||
<Typography variant="body2">{user?.email}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {active.profile_id}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete profile
|
||||
</Button>
|
||||
</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>
|
||||
</CardContent>
|
||||
</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 { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||
import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||
import { useProfileStore } from '../../store/useStore';
|
||||
|
||||
/** A compact dropdown for switching the active profile (the subject of care
|
||||
* whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles`
|
||||
* on mount so the list + per-profile DEKs are ready. */
|
||||
* + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */
|
||||
export const ProfileSwitcher: FC = () => {
|
||||
const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } =
|
||||
const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } =
|
||||
useProfileStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
(async () => {
|
||||
await loadProfiles();
|
||||
// Fetch profiles shared TO me after owned profiles are loaded (the
|
||||
// identity private key must be unwrapped first; loadSharedWithMe is a
|
||||
// no-op if it isn't available yet).
|
||||
await loadSharedWithMe();
|
||||
})();
|
||||
}, [loadProfiles, loadSharedWithMe]);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return isLoading ? (
|
||||
|
|
@ -36,10 +42,21 @@ export const ProfileSwitcher: FC = () => {
|
|||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'.MuiSvgIcon-root': { color: 'common.white' },
|
||||
}}
|
||||
renderValue={(value) => {
|
||||
const p = profiles.find((x) => x.profile_id === value);
|
||||
return p ? (p.name || p.relationship || p.profile_id) : '';
|
||||
}}
|
||||
>
|
||||
{profiles.map((p) => (
|
||||
<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>
|
||||
))}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import {
|
|||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
clearProfileDeks,
|
||||
|
|
@ -282,3 +284,52 @@ describe('per-profile DEK isolation (Phase A2)', () => {
|
|||
expect(getProfileDek('profile_child')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('profile sharing: X25519 envelope (Phase B)', () => {
|
||||
it('owner wraps a profile DEK to a recipient, who unwraps it', async () => {
|
||||
if (!(await x25519Supported())) {
|
||||
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
|
||||
return;
|
||||
}
|
||||
// Two accounts, each with their own identity keypair.
|
||||
const owner = await generateIdentityKeyPair();
|
||||
const recipient = await generateIdentityKeyPair();
|
||||
|
||||
// A profile DEK the owner created (would normally be wrapped under the
|
||||
// owner's account DEK too — irrelevant for this test).
|
||||
const profileDek = await generateProfileDek();
|
||||
|
||||
// Owner wraps the profile DEK to the recipient's public key. Internally
|
||||
// this derives a fresh ephemeral keypair + ECDH(recipient pub, ephemeral
|
||||
// priv) and AES-GCM-wraps the DEK under the shared secret.
|
||||
const envelope = await wrapProfileDekToRecipient(
|
||||
profileDek,
|
||||
recipient.publicKey,
|
||||
owner.privateKey, // not actually used by wrap (only recipient pub matters)
|
||||
);
|
||||
expect(envelope.ephemeralPublicKey).not.toBe('');
|
||||
expect(envelope.wrappedProfileDek.data).not.toBe('');
|
||||
|
||||
// Recipient unwraps with their private key + the envelope's ephemeral pub.
|
||||
const recoveredDek = await unwrapProfileDekFromShare(
|
||||
envelope.wrappedProfileDek,
|
||||
envelope.ephemeralPublicKey,
|
||||
recipient.privateKey,
|
||||
);
|
||||
expect(recoveredDek).toBeDefined();
|
||||
|
||||
// The recovered DEK encrypts/decrypts the same data the original did.
|
||||
const data = await encrypt('shared-medication', profileDek);
|
||||
expect(await decrypt(data, recoveredDek)).toBe('shared-medication');
|
||||
|
||||
// A *different* recipient's private key cannot unwrap the share.
|
||||
const stranger = await generateIdentityKeyPair();
|
||||
await expect(
|
||||
unwrapProfileDekFromShare(
|
||||
envelope.wrappedProfileDek,
|
||||
envelope.ephemeralPublicKey,
|
||||
stranger.privateKey,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ export {
|
|||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
type SharedProfileDekEnvelope,
|
||||
setEncKey,
|
||||
getEncKey,
|
||||
clearEncKey,
|
||||
|
|
|
|||
|
|
@ -147,6 +147,97 @@ export async function unwrapProfileDek(
|
|||
return unwrapDek(payload, accountDek);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile sharing: X25519 envelope (Phase B).
|
||||
//
|
||||
// To share a profile, the owner generates a fresh ephemeral X25519 keypair,
|
||||
// ECDH-derives a shared secret from (ephemeral private, recipient public),
|
||||
// uses it as an AES-GCM wrapping key, and wraps the profile DEK. The server
|
||||
// stores the wrapped DEK + the ephemeral public key (plaintext). The recipient
|
||||
// ECDH-derives the same shared secret from (their private, ephemeral public)
|
||||
// and unwraps the profile DEK. See docs/adr/multi-person-sharing.md §3.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape returned by the owner-side share wrapping. */
|
||||
export interface SharedProfileDekEnvelope {
|
||||
/** base64 ephemeral X25519 public key (send to the server plaintext). */
|
||||
ephemeralPublicKey: string;
|
||||
/** Profile DEK wrapped under the ECDH-derived key. */
|
||||
wrappedProfileDek: CipherPayload;
|
||||
}
|
||||
|
||||
/** Import a base64-raw X25519 public key for ECDH. */
|
||||
async function importX25519Public(publicKeyB64: string): Promise<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import {
|
|||
ProfileWireResponse,
|
||||
CreateProfileRequest,
|
||||
UpdateProfileRequest,
|
||||
SharedProfileWireResponse,
|
||||
CreateProfileShareRequest,
|
||||
ProfileShareListing,
|
||||
PublicKeyResponse,
|
||||
HealthStatWireResponse,
|
||||
UpdateHealthStatRequest,
|
||||
EncryptedFieldWire,
|
||||
|
|
@ -260,6 +264,50 @@ class ApiService {
|
|||
await this.client.delete(`/profiles/${profileId}`);
|
||||
}
|
||||
|
||||
// ---- Profile sharing (Phase B: X25519 envelope) ----
|
||||
|
||||
/** Public endpoint — recipient identity public key lookup by email. */
|
||||
async getUserPublicKey(email: string): Promise<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) ----
|
||||
|
||||
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
encrypt as encryptRaw,
|
||||
decrypt as decryptRaw,
|
||||
setEncKey,
|
||||
|
|
@ -19,6 +21,7 @@ import {
|
|||
clearIdentityPrivate,
|
||||
clearProfileDeks,
|
||||
setIdentityPrivate,
|
||||
getIdentityPrivate,
|
||||
getEncKey,
|
||||
getActiveProfileDek,
|
||||
getActiveProfileId,
|
||||
|
|
@ -37,6 +40,7 @@ import {
|
|||
DrugInteraction,
|
||||
AdherenceStats,
|
||||
Profile,
|
||||
ProfileShareListing,
|
||||
Appointment,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
|
|
@ -137,6 +141,18 @@ interface ProfileState {
|
|||
relationship?: string;
|
||||
}) => Promise<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;
|
||||
}
|
||||
|
||||
|
|
@ -805,6 +821,7 @@ export const useProfileStore = create<ProfileState>()(
|
|||
activeProfileId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
profileShares: {},
|
||||
|
||||
loadProfiles: async () => {
|
||||
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 }),
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -371,6 +371,49 @@ export interface UpdateProfileRequest {
|
|||
relationship?: string;
|
||||
}
|
||||
|
||||
// A profile shared TO the current user (recipient view). Carries the
|
||||
// per-share ECDH-wrapped DEK the recipient unwraps with their identity
|
||||
// private key (not the account-wrapped form owned profiles use).
|
||||
export interface SharedProfileWireResponse {
|
||||
profile_id: string;
|
||||
owner_account_id: string;
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
kind: string;
|
||||
relationship: string;
|
||||
ephemeral_public_key: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Request body for POST /profiles/:id/shares (owner wraps client-side).
|
||||
export interface CreateProfileShareRequest {
|
||||
recipient_email: string;
|
||||
ephemeral_public_key: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
permissions?: string[];
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
// A share the owner has created (listing view).
|
||||
export interface ProfileShareListing {
|
||||
profile_id: string;
|
||||
recipient_user_id: string;
|
||||
recipient_email: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// Response from GET /users/public-key?email=...
|
||||
export interface PublicKeyResponse {
|
||||
user_id: string;
|
||||
identity_public_key: string;
|
||||
}
|
||||
|
||||
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||
export interface DoseLog {
|
||||
id?: string;
|
||||
|
|
@ -411,6 +454,10 @@ export interface Profile {
|
|||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** True if this profile is shared TO the current user (recipient view).
|
||||
* Undefined/false for profiles the user owns. Set by the store when merging
|
||||
* shared-with-me results. */
|
||||
is_shared?: boolean;
|
||||
}
|
||||
|
||||
// Error Types
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue