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.
171 lines
5.8 KiB
Rust
171 lines
5.8 KiB
Rust
use futures::stream::StreamExt;
|
|
use mongodb::{
|
|
bson::{doc, oid::ObjectId, DateTime},
|
|
Collection,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// A profile (a "subject of care" — a person or pet whose health data is
|
|
/// tracked). One account owns zero or more profiles. All health data
|
|
/// (medications, appointments, health stats) is scoped to a profile and
|
|
/// encrypted under that profile's DEK.
|
|
///
|
|
/// Zero-knowledge layers (both opaque to the server):
|
|
/// - `name` / `wrapped_profile_dek` are AES-256-GCM ciphertext blobs the
|
|
/// server stores verbatim and cannot read.
|
|
/// - `name` is the profile's display name encrypted under the profile DEK.
|
|
/// - `wrapped_profile_dek` is the profile DEK encrypted under the owner's
|
|
/// account DEK. The client unwraps it at login/unlock (after unwrapping
|
|
/// the account DEK) to get the profile DEK.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Profile {
|
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
|
pub id: Option<ObjectId>,
|
|
#[serde(rename = "profileId")]
|
|
pub profile_id: String,
|
|
/// Owner account id (Mongo ObjectId hex). Replaces the 1:1 `user_id`
|
|
/// semantics from the pre-A2 single-profile model.
|
|
#[serde(rename = "ownerAccountId")]
|
|
pub owner_account_id: String,
|
|
/// Kept populated = owner_account_id for backward compat with existing
|
|
/// medication/appointment filters that key on `userId`. Same value; do
|
|
/// not rely on it for ownership — use `owner_account_id`.
|
|
#[serde(rename = "userId")]
|
|
pub user_id: String,
|
|
#[serde(rename = "familyId")]
|
|
pub family_id: Option<String>,
|
|
/// Display name — opaque, encrypted under the profile DEK.
|
|
#[serde(rename = "name")]
|
|
pub name: String,
|
|
#[serde(rename = "nameIv")]
|
|
pub name_iv: String,
|
|
#[serde(rename = "nameAuthTag")]
|
|
pub name_auth_tag: String,
|
|
/// "human" | "pet" — what kind of subject this profile tracks.
|
|
#[serde(rename = "kind", default = "default_kind")]
|
|
pub kind: String,
|
|
/// Freeform relationship to the owner: "self", "child", "spouse",
|
|
/// "parent", "pet", ...
|
|
#[serde(rename = "relationship", default)]
|
|
pub relationship: String,
|
|
#[serde(rename = "role", default = "default_role")]
|
|
pub role: String,
|
|
#[serde(rename = "permissions", default)]
|
|
pub permissions: Vec<String>,
|
|
/// Profile DEK wrapped under the owner's account DEK (opaque ciphertext).
|
|
#[serde(rename = "wrappedProfileDek")]
|
|
pub wrapped_profile_dek: String,
|
|
#[serde(rename = "wrappedProfileDekIv")]
|
|
pub wrapped_profile_dek_iv: String,
|
|
#[serde(rename = "createdAt")]
|
|
pub created_at: DateTime,
|
|
#[serde(rename = "updatedAt")]
|
|
pub updated_at: DateTime,
|
|
}
|
|
|
|
fn default_kind() -> String {
|
|
"human".to_string()
|
|
}
|
|
|
|
fn default_role() -> String {
|
|
"patient".to_string()
|
|
}
|
|
|
|
pub struct ProfileRepository {
|
|
collection: Collection<Profile>,
|
|
}
|
|
|
|
impl ProfileRepository {
|
|
pub fn new(collection: Collection<Profile>) -> Self {
|
|
Self { collection }
|
|
}
|
|
|
|
pub async fn create(&self, profile: &Profile) -> mongodb::error::Result<()> {
|
|
self.collection.insert_one(profile, None).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Look up a profile by its application-level profile id.
|
|
pub async fn find_by_profile_id(
|
|
&self,
|
|
profile_id: &str,
|
|
) -> mongodb::error::Result<Option<Profile>> {
|
|
self.collection
|
|
.find_one(doc! { "profileId": profile_id }, None)
|
|
.await
|
|
}
|
|
|
|
/// Look up a profile by id AND owner — used for ownership-scoped access.
|
|
/// Returns None if the profile doesn't exist or doesn't belong to `owner`.
|
|
pub async fn find_by_profile_id_owned(
|
|
&self,
|
|
profile_id: &str,
|
|
owner: &str,
|
|
) -> mongodb::error::Result<Option<Profile>> {
|
|
self.collection
|
|
.find_one(
|
|
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// All profiles owned by an account.
|
|
pub async fn find_all_by_owner(&self, owner: &str) -> mongodb::error::Result<Vec<Profile>> {
|
|
let mut cursor = self
|
|
.collection
|
|
.find(doc! { "ownerAccountId": owner }, None)
|
|
.await?;
|
|
let mut out = Vec::new();
|
|
while let Some(p) = cursor.next().await {
|
|
out.push(p?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Replace a profile's mutable fields (display name blob, kind,
|
|
/// relationship), keyed by (profile_id, owner). Returns the updated doc
|
|
/// or None if the profile doesn't exist / isn't owned by `owner`.
|
|
pub async fn update_profile(
|
|
&self,
|
|
profile_id: &str,
|
|
owner: &str,
|
|
name_data: &str,
|
|
name_iv: &str,
|
|
name_auth_tag: &str,
|
|
kind: &str,
|
|
relationship: &str,
|
|
) -> mongodb::error::Result<Option<Profile>> {
|
|
self.collection
|
|
.find_one_and_update(
|
|
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
|
doc! { "$set": {
|
|
"name": name_data,
|
|
"nameIv": name_iv,
|
|
"nameAuthTag": name_auth_tag,
|
|
"kind": kind,
|
|
"relationship": relationship,
|
|
"updatedAt": DateTime::now()
|
|
}},
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Delete a profile keyed by (profile_id, owner). Returns true if a doc
|
|
/// was deleted, false if it didn't exist or wasn't owned by `owner`.
|
|
pub async fn delete_profile(
|
|
&self,
|
|
profile_id: &str,
|
|
owner: &str,
|
|
) -> mongodb::error::Result<bool> {
|
|
let res = self
|
|
.collection
|
|
.delete_one(
|
|
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
|
None,
|
|
)
|
|
.await?;
|
|
Ok(res.deleted_count > 0)
|
|
}
|
|
}
|