Implements the 3-tier key model from the multi-person sharing ADR: each account owns multiple profiles (a person or pet — a 'subject of care'), and each profile has its own random AES-256-GCM DEK. All health data is now encrypted under the active profile's DEK, not the account-wide DEK. The account DEK wraps each profile DEK; the server stores only opaque wrapped blobs. Per the ADR: DB wipe, no migration (no real user data). This unblocks Phase B (sharing) — there is now a per-profile key to wrap to a recipient's X25519 public key. Backend: - Profile model: owner_account_id, kind (human/pet), relationship, wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner, find_by_profile_id_owned, update_profile, delete_profile — all ownership-scoped. - Profile handlers: GET/POST /api/profiles, GET/PUT/DELETE /api/profiles/:id. Removed /api/profiles/me. Renamed users.rs get_profile/update_profile (the /api/users/me handlers) to get_account/update_account to resolve a name collision. - Register accepts default_profile_* fields and auto-creates the self profile when the client provides a wrapped profile DEK. - HealthStatistic + Appointment gain profile_id and ?profile_id= filtering (health stats previously had no profile binding). - New profile_tests.rs: multi-profile CRUD + ownership isolation + register-with-default-profile. Fixed the zk health-stat test to send the now-required profile_id. Frontend: - crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek + in-memory per-profile DEK store with an active-profile concept. - useProfileStore rewritten: holds profiles[], activeProfileId; loadProfiles unwraps each profile DEK; create/update/delete. All 11 encrypt/decrypt sites switched from getEncKey() to getActiveProfileDek(). load actions pass ?profile_id= so only the active profile's rows come back. - ProfileEditor rewritten for the new store (edit active profile, create/delete). New ProfileSwitcher in the Dashboard AppBar. - MedicationManager / AppointmentsManager use the active profile id instead of the hardcoded profile_<user_id>. - 2 new crypto tests for per-profile DEK isolation; updated store + component tests for the active-profile-DEK model. Verification: backend cargo build/clippy/fmt green, tests compile (integration tests run in CI — Mongo is fixed there). Frontend tsc clean, 30/30 tests pass. Closes nothing yet (Phase B/C/D remain). Refs #3.
223 lines
7.4 KiB
Rust
223 lines
7.4 KiB
Rust
use super::health_data::EncryptedField;
|
|
use futures::StreamExt;
|
|
use mongodb::bson::{doc, oid::ObjectId, DateTime};
|
|
use mongodb::Collection;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
fn default_status() -> String {
|
|
"upcoming".to_string()
|
|
}
|
|
|
|
/// Zero-knowledge wire response. The server returns ONLY metadata + the opaque
|
|
/// encrypted data blob + the top-level `status` (filterable without decrypting).
|
|
#[derive(Debug, Serialize)]
|
|
pub struct AppointmentResponse {
|
|
pub id: String,
|
|
pub appointment_id: String,
|
|
pub user_id: String,
|
|
pub profile_id: String,
|
|
pub status: String,
|
|
pub encrypted_data: crate::models::medication::EncryptedFieldWire,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
impl std::convert::From<Appointment> for AppointmentResponse {
|
|
fn from(a: Appointment) -> Self {
|
|
AppointmentResponse {
|
|
id: a.id.map(|o| o.to_hex()).unwrap_or_default(),
|
|
appointment_id: a.appointment_id,
|
|
user_id: a.user_id,
|
|
profile_id: a.profile_id,
|
|
status: a.status,
|
|
encrypted_data: crate::models::medication::EncryptedFieldWire {
|
|
data: a.appointment_data.data,
|
|
iv: a.appointment_data.iv,
|
|
auth_tag: a.appointment_data.auth_tag,
|
|
},
|
|
created_at: system_time_to_rfc3339(a.created_at.to_system_time()),
|
|
updated_at: system_time_to_rfc3339(a.updated_at.to_system_time()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Format a SystemTime as an ISO 8601 (RFC 3339) string.
|
|
fn system_time_to_rfc3339(t: std::time::SystemTime) -> String {
|
|
let secs = t
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
chrono::DateTime::from_timestamp(secs, 0)
|
|
.map(|dt| dt.to_rfc3339())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Appointment {
|
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
|
pub id: Option<ObjectId>,
|
|
#[serde(rename = "appointmentId")]
|
|
pub appointment_id: String,
|
|
#[serde(rename = "userId")]
|
|
pub user_id: String,
|
|
#[serde(rename = "profileId")]
|
|
pub profile_id: String,
|
|
#[serde(rename = "appointmentData")]
|
|
pub appointment_data: EncryptedField,
|
|
#[serde(rename = "reminders")]
|
|
pub reminders: Vec<AppointmentReminder>,
|
|
/// Top-level status so it can be filtered without decrypting the blob.
|
|
#[serde(rename = "status", default = "default_status")]
|
|
pub status: String,
|
|
#[serde(rename = "createdAt")]
|
|
pub created_at: DateTime,
|
|
#[serde(rename = "updatedAt")]
|
|
pub updated_at: DateTime,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AppointmentReminder {
|
|
#[serde(rename = "reminderId")]
|
|
pub reminder_id: String,
|
|
#[serde(rename = "scheduledTime")]
|
|
pub scheduled_time: String,
|
|
}
|
|
|
|
// ============================================================================
|
|
// REQUEST TYPES — opaque blobs; the client encrypts, the server stores verbatim.
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateAppointmentRequest {
|
|
pub profile_id: String,
|
|
pub encrypted_data: crate::models::medication::EncryptedFieldWire,
|
|
/// Defaults to "upcoming".
|
|
#[serde(default = "default_status")]
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateAppointmentRequest {
|
|
pub encrypted_data: Option<crate::models::medication::EncryptedFieldWire>,
|
|
pub status: Option<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// REPOSITORY
|
|
// ============================================================================
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppointmentRepository {
|
|
collection: Collection<Appointment>,
|
|
}
|
|
|
|
impl AppointmentRepository {
|
|
pub fn new(collection: Collection<Appointment>) -> Self {
|
|
Self { collection }
|
|
}
|
|
|
|
pub async fn create(
|
|
&self,
|
|
appointment: Appointment,
|
|
) -> Result<Appointment, Box<dyn std::error::Error>> {
|
|
self.collection
|
|
.insert_one(appointment.clone(), None)
|
|
.await?;
|
|
Ok(appointment)
|
|
}
|
|
|
|
/// List a user's appointments, optionally filtered by top-level `status`
|
|
/// and/or `profileId`.
|
|
pub async fn find_by_user_filtered(
|
|
&self,
|
|
user_id: &str,
|
|
status: Option<&str>,
|
|
profile_id: Option<&str>,
|
|
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
|
|
let mut filter = doc! { "userId": user_id };
|
|
if let Some(s) = status {
|
|
filter.insert("status", s);
|
|
}
|
|
if let Some(p) = profile_id {
|
|
filter.insert("profileId", p);
|
|
}
|
|
let mut cursor = self.collection.find(filter, None).await?;
|
|
let mut appointments = Vec::new();
|
|
while let Some(appt) = cursor.next().await {
|
|
appointments.push(appt?);
|
|
}
|
|
Ok(appointments)
|
|
}
|
|
|
|
pub async fn find_by_appointment_id(
|
|
&self,
|
|
appointment_id: &str,
|
|
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
|
|
let filter = doc! { "appointmentId": appointment_id };
|
|
Ok(self.collection.find_one(filter, None).await?)
|
|
}
|
|
|
|
/// Whole-blob replace — the client re-encrypts and sends the new blob.
|
|
pub async fn update_by_appointment_id(
|
|
&self,
|
|
appointment_id: &str,
|
|
updates: UpdateAppointmentRequest,
|
|
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
|
|
let filter = doc! { "appointmentId": appointment_id };
|
|
let mut update_doc = doc! { "updatedAt": DateTime::now() };
|
|
|
|
if let Some(blob) = updates.encrypted_data {
|
|
update_doc.insert("appointmentData.data", blob.data);
|
|
update_doc.insert("appointmentData.iv", blob.iv);
|
|
update_doc.insert("appointmentData.authTag", blob.auth_tag);
|
|
}
|
|
if let Some(status) = updates.status {
|
|
update_doc.insert("status", status);
|
|
}
|
|
|
|
Ok(self
|
|
.collection
|
|
.find_one_and_update(filter, doc! { "$set": update_doc }, None)
|
|
.await?)
|
|
}
|
|
|
|
pub async fn delete_by_appointment_id(
|
|
&self,
|
|
appointment_id: &str,
|
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
|
let filter = doc! { "appointmentId": appointment_id };
|
|
let result = self.collection.delete_one(filter, None).await?;
|
|
Ok(result.deleted_count > 0)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn appointment_response_echoes_opaque_blob_and_status() {
|
|
let appt = Appointment {
|
|
id: Some(ObjectId::new()),
|
|
appointment_id: "apt-1".to_string(),
|
|
user_id: "user-1".to_string(),
|
|
profile_id: "profile_user-1".to_string(),
|
|
appointment_data: EncryptedField {
|
|
data: "opaque-ciphertext".to_string(),
|
|
encrypted: true,
|
|
iv: "base64-iv".to_string(),
|
|
auth_tag: String::new(),
|
|
},
|
|
reminders: vec![],
|
|
status: "completed".to_string(),
|
|
created_at: DateTime::now(),
|
|
updated_at: DateTime::now(),
|
|
};
|
|
|
|
let resp: AppointmentResponse = appt.into();
|
|
assert_eq!(resp.appointment_id, "apt-1");
|
|
assert_eq!(resp.status, "completed");
|
|
assert_eq!(resp.encrypted_data.data, "opaque-ciphertext");
|
|
assert!(!resp.id.is_empty());
|
|
}
|
|
}
|