feat: per-profile DEKs + multi-profile (Phase A2, #3)
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.
This commit is contained in:
parent
9807434c5f
commit
eb2c2aa546
25 changed files with 1322 additions and 206 deletions
|
|
@ -1,35 +1,76 @@
|
|||
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,
|
||||
#[serde(rename = "role")]
|
||||
/// "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")]
|
||||
#[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>,
|
||||
}
|
||||
|
|
@ -44,6 +85,7 @@ impl ProfileRepository {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a profile by its application-level profile id.
|
||||
pub async fn find_by_profile_id(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
|
|
@ -53,32 +95,77 @@ impl ProfileRepository {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Look up a profile by its owning user id.
|
||||
pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result<Option<Profile>> {
|
||||
/// 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! { "userId": user_id }, None)
|
||||
.find_one(
|
||||
doc! { "profileId": profile_id, "ownerAccountId": owner },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update the profile's display name (opaque client-encrypted blob).
|
||||
pub async fn update_encrypted_name(
|
||||
/// 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,
|
||||
user_id: &str,
|
||||
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! { "userId": user_id },
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue