feat: per-profile DEKs + multi-profile (Phase A2, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 39s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m45s
Lint and Build / test (pull_request) Failing after 2m55s

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:
goose 2026-07-18 23:45:01 -03:00
parent 9807434c5f
commit eb2c2aa546
25 changed files with 1322 additions and 206 deletions

View file

@ -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)
}
}