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

@ -40,8 +40,8 @@ pub fn build_app(state: AppState) -> Router {
// Build protected routes (auth required)
let protected_routes = Router::new()
// User profile management
.route("/api/users/me", get(handlers::get_profile))
.route("/api/users/me", put(handlers::update_profile))
.route("/api/users/me", get(handlers::get_account))
.route("/api/users/me", put(handlers::update_account))
.route("/api/users/me", delete(handlers::delete_account))
.route("/api/users/me/change-password", post(handlers::change_password))
// User settings
@ -54,9 +54,12 @@ pub fn build_app(state: AppState) -> Router {
.route("/api/shares/:id", delete(handlers::delete_share))
// Permission checking
.route("/api/permissions/check", post(handlers::check_permission))
// Profile management (Phase 3c)
.route("/api/profiles/me", get(handlers::get_my_profile))
.route("/api/profiles/me", put(handlers::update_my_profile))
// Profile management (Phase A2: multi-profile + per-profile DEKs)
.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/:id", put(handlers::update_profile))
.route("/api/profiles/:id", delete(handlers::delete_profile))
// Session management (Phase 2.6)
.route("/api/sessions", get(handlers::get_sessions))
.route("/api/sessions/:id", delete(handlers::revoke_session))

View file

@ -20,6 +20,8 @@ use mongodb::bson::DateTime;
pub struct AppointmentListQuery {
/// Filter by status (upcoming/completed/cancelled). Optional.
pub status: Option<String>,
/// Filter by profile id. Optional.
pub profile_id: Option<String>,
}
pub async fn create_appointment(
@ -68,7 +70,11 @@ pub async fn list_appointments(
let repo = AppointmentRepository::new(database.collection("appointments"));
match repo
.find_by_user_filtered(&claims.sub, query.status.as_deref())
.find_by_user_filtered(
&claims.sub,
query.status.as_deref(),
query.profile_id.as_deref(),
)
.await
{
Ok(appointments) => {

View file

@ -32,6 +32,15 @@ pub struct RegisterRequest {
/// Account X25519 identity private key, wrapped under the account DEK.
pub identity_private_key_wrapped: Option<String>,
pub identity_private_key_wrapped_iv: Option<String>,
/// Default-profile display name (opaque, encrypted under the profile DEK)
/// and the profile DEK wrapped under the account DEK. When present, the
/// register flow auto-creates a "self" profile; when absent, no profile is
/// created and the client is expected to POST /api/profiles on first run.
pub default_profile_name_data: Option<String>,
pub default_profile_name_iv: Option<String>,
pub default_profile_name_auth_tag: Option<String>,
pub default_wrapped_profile_dek: Option<String>,
pub default_wrapped_profile_dek_iv: Option<String>,
}
#[derive(Debug, Serialize)]
@ -145,17 +154,29 @@ pub async fn register(
.await;
}
// Auto-create a default profile for the new user. The profile_id is
// deterministic (profile_<user_id>) and is the contract the frontend
// uses for medication creation. Best-effort: registration still
// succeeds if this fails (GET /profiles/me lazily creates one).
let database = state.db.get_database();
let profile_repo =
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
let profile =
crate::handlers::profile::build_default_profile(&id.to_string(), &req.username);
if let Err(e) = profile_repo.create(&profile).await {
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
// Auto-create the default "self" profile for the new user, but only
// when the client provided a wrapped profile DEK. The profile_id is
// deterministic (profile_<owner>). Best-effort: registration still
// succeeds if this fails or if no wrapped DEK was provided (the
// client creates the profile via POST /api/profiles on first run).
if let (Some(wrapped_dek), Some(wrapped_dek_iv)) = (
req.default_wrapped_profile_dek.as_ref(),
req.default_wrapped_profile_dek_iv.as_ref(),
) {
let database = state.db.get_database();
let profile_repo =
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
let profile = crate::handlers::profile::build_default_profile(
&id.to_string(),
wrapped_dek,
wrapped_dek_iv,
req.default_profile_name_data.as_deref().unwrap_or(""),
req.default_profile_name_iv.as_deref().unwrap_or(""),
req.default_profile_name_auth_tag.as_deref().unwrap_or(""),
);
if let Err(e) = profile_repo.create(&profile).await {
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
}
}
id

View file

@ -3,7 +3,7 @@ use crate::config::AppState;
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
use crate::models::medication::EncryptedFieldWire;
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Extension, Json,
@ -14,9 +14,16 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct CreateHealthStatRequest {
pub encrypted_data: EncryptedFieldWire,
/// Which profile this stat belongs to.
pub profile_id: String,
pub recorded_at: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ListHealthStatsQuery {
pub profile_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateHealthStatRequest {
pub encrypted_data: EncryptedFieldWire,
@ -41,6 +48,7 @@ pub async fn create_health_stat(
let stat = HealthStatistic {
id: None,
user_id: claims.sub.clone(),
profile_id: req.profile_id,
encrypted_data: req.encrypted_data,
recorded_at: req
.recorded_at
@ -70,6 +78,7 @@ pub async fn create_health_stat(
pub async fn list_health_stats(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Query(query): Query<ListHealthStatsQuery>,
) -> impl IntoResponse {
let repo = match state.health_stats_repo.as_ref() {
Some(r) => r,
@ -81,7 +90,10 @@ pub async fn list_health_stats(
.into_response()
}
};
match repo.find_by_user(&claims.sub).await {
match repo
.find_by_user_filtered(&claims.sub, query.profile_id.as_deref())
.await
{
Ok(stats) => {
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(resp)).into_response()

View file

@ -25,9 +25,9 @@ pub use medications::{
log_dose, update_medication,
};
pub use permissions::check_permission;
pub use profile::{get_my_profile, update_my_profile};
pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile};
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_profile, get_settings, update_profile, update_settings,
change_password, delete_account, get_account, get_settings, update_account, update_settings,
};

View file

@ -1,5 +1,5 @@
use axum::{
extract::{Extension, State},
extract::{Extension, Path, State},
http::StatusCode,
Json,
};
@ -12,17 +12,21 @@ use crate::{
models::profile::{Profile, ProfileRepository},
};
/// The profile as exposed to clients. The display name is returned as an opaque
/// client-encrypted blob (the server cannot read it).
/// The profile as exposed to clients. Display name + wrapped profile DEK are
/// returned as opaque client-encrypted blobs (the server cannot read either).
#[derive(Debug, Serialize)]
pub struct ProfileResponse {
pub profile_id: String,
pub user_id: String,
/// Opaque client-encrypted display name.
pub owner_account_id: String,
pub name_data: String,
pub name_iv: String,
pub kind: String,
pub relationship: String,
pub role: String,
pub permissions: Vec<String>,
/// Profile DEK wrapped under the owner's account DEK (opaque).
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
pub created_at: DateTime,
pub updated_at: DateTime,
}
@ -31,98 +35,212 @@ impl From<Profile> for ProfileResponse {
fn from(p: Profile) -> Self {
Self {
profile_id: p.profile_id,
user_id: p.user_id,
owner_account_id: p.owner_account_id,
name_data: p.name,
name_iv: p.name_iv,
kind: p.kind,
relationship: p.relationship,
role: p.role,
permissions: p.permissions,
wrapped_profile_dek: p.wrapped_profile_dek,
wrapped_profile_dek_iv: p.wrapped_profile_dek_iv,
created_at: p.created_at,
updated_at: p.updated_at,
}
}
}
/// Create the default "patient" profile for a freshly registered user. Called
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
/// is the contract the frontend relies on for medication creation. The name is
/// empty (not encrypted) because the server has no encryption key; the client
/// sets it via PUT /profiles/me after deriving its key.
pub fn build_default_profile(user_id: &str, _name: &str) -> Profile {
/// Build a default "self" profile for a freshly registered user. Called from
/// `register` when the client provides a wrapped profile DEK for the default
/// profile. The server has no encryption key, so the name blob and wrapped
/// profile DEK are taken verbatim from the caller (the client generated the
/// profile DEK and wrapped it under the account DEK).
pub fn build_default_profile(
owner_account_id: &str,
wrapped_profile_dek: &str,
wrapped_profile_dek_iv: &str,
name_data: &str,
name_iv: &str,
name_auth_tag: &str,
) -> Profile {
let now = DateTime::now();
Profile {
id: None,
profile_id: format!("profile_{user_id}"),
user_id: user_id.to_string(),
// profile_<owner> keeps the deterministic-id contract the frontend
// used pre-A2 for the self profile.
profile_id: format!("profile_{owner_account_id}"),
owner_account_id: owner_account_id.to_string(),
user_id: owner_account_id.to_string(),
family_id: None,
name: String::new(),
name_iv: String::new(),
name_auth_tag: String::new(),
name: name_data.to_string(),
name_iv: name_iv.to_string(),
name_auth_tag: name_auth_tag.to_string(),
kind: "human".to_string(),
relationship: "self".to_string(),
role: "patient".to_string(),
permissions: vec!["read:self".to_string(), "write:self".to_string()],
wrapped_profile_dek: wrapped_profile_dek.to_string(),
wrapped_profile_dek_iv: wrapped_profile_dek_iv.to_string(),
created_at: now,
updated_at: now,
}
}
/// PUT /profiles/me body — the client sends the encrypted name blob.
fn profile_repo(state: &AppState) -> ProfileRepository {
ProfileRepository::new(state.db.get_database().collection("profiles"))
}
// ---------------------------------------------------------------------------
// GET /api/profiles — list all profiles owned by the current account
// ---------------------------------------------------------------------------
pub async fn list_profiles(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> Result<Json<Vec<ProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
match repo.find_all_by_owner(&claims.sub).await {
Ok(profiles) => {
let resp: Vec<ProfileResponse> = profiles.into_iter().map(Into::into).collect();
Ok(Json(resp))
}
Err(e) => {
tracing::error!("Profile list failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// ---------------------------------------------------------------------------
// POST /api/profiles — create a new profile (child, pet, ...).
// The client generates the profile DEK, wraps it under the account DEK, and
// sends the wrapped blob + the encrypted display name.
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct UpdateProfileNameRequest {
pub struct CreateProfileRequest {
/// Optional explicit profile id; defaults to a fresh UUID if absent.
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default = "default_kind_value")]
pub kind: String,
#[serde(default)]
pub relationship: String,
pub name_data: String,
pub name_iv: String,
#[serde(default)]
pub name_auth_tag: String,
/// Profile DEK wrapped under the account DEK (opaque ciphertext).
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
}
/// GET /api/profiles/me — the current user's profile. If for some reason the
/// auto-created profile is missing, lazily create it.
pub async fn get_my_profile(
fn default_kind_value() -> String {
"human".to_string()
}
pub async fn create_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let database = state.db.get_database();
let repo = ProfileRepository::new(database.collection("profiles"));
let profile = match repo.find_by_user_id(&claims.sub).await {
Ok(Some(p)) => p,
Ok(None) => {
// Lazily create if missing (e.g. users registered before this code shipped).
let p = build_default_profile(&claims.sub, &claims.sub);
if let Err(e) = repo.create(&p).await {
tracing::error!("Failed to lazily create profile: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "failed to load profile" })),
));
}
p
}
Err(e) => {
tracing::error!("Profile lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Json(req): Json<CreateProfileRequest>,
) -> Result<(StatusCode, Json<ProfileResponse>), (StatusCode, Json<serde_json::Value>)> {
let now = DateTime::now();
let profile = Profile {
id: None,
profile_id: req
.profile_id
.unwrap_or_else(|| format!("p_{}", uuid::Uuid::new_v4())),
owner_account_id: claims.sub.clone(),
user_id: claims.sub.clone(),
family_id: None,
name: req.name_data,
name_iv: req.name_iv,
name_auth_tag: req.name_auth_tag,
kind: req.kind,
relationship: req.relationship,
role: "patient".to_string(),
permissions: vec!["read:self".to_string(), "write:self".to_string()],
wrapped_profile_dek: req.wrapped_profile_dek,
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
created_at: now,
updated_at: now,
};
Ok(Json(ProfileResponse::from(profile)))
let repo = profile_repo(&state);
if let Err(e) = repo.create(&profile).await {
tracing::error!("Profile create failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Ok((StatusCode::CREATED, Json(ProfileResponse::from(profile))))
}
/// PUT /api/profiles/me — update the profile's encrypted display-name blob.
pub async fn update_my_profile(
// ---------------------------------------------------------------------------
// GET /api/profiles/:id — fetch one owned profile
// ---------------------------------------------------------------------------
pub async fn get_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Json(req): Json<UpdateProfileNameRequest>,
Path(profile_id): Path<String>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let database = state.db.get_database();
let repo = ProfileRepository::new(database.collection("profiles"));
let repo = profile_repo(&state);
match repo
.update_encrypted_name(
.find_by_profile_id_owned(&profile_id, &claims.sub)
.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 lookup failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// ---------------------------------------------------------------------------
// PUT /api/profiles/:id — update an owned profile's mutable fields
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct UpdateProfileRequest {
pub name_data: String,
pub name_iv: String,
#[serde(default)]
pub name_auth_tag: String,
#[serde(default = "default_kind_value")]
pub kind: String,
#[serde(default)]
pub relationship: String,
}
pub async fn update_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<UpdateProfileRequest>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
match repo
.update_profile(
&profile_id,
&claims.sub,
&req.name_data,
&req.name_iv,
&req.name_auth_tag,
&req.kind,
&req.relationship,
)
.await
{
@ -140,3 +258,29 @@ pub async fn update_my_profile(
}
}
}
// ---------------------------------------------------------------------------
// DELETE /api/profiles/:id — delete an owned profile
// ---------------------------------------------------------------------------
pub async fn delete_profile(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
match repo.delete_profile(&profile_id, &claims.sub).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
)),
Err(e) => {
tracing::error!("Profile delete failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}

View file

@ -39,7 +39,7 @@ pub struct UpdateProfileRequest {
pub username: Option<String>,
}
pub async fn get_profile(
pub async fn get_account(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> impl IntoResponse {
@ -79,7 +79,7 @@ pub async fn get_profile(
}
}
pub async fn update_profile(
pub async fn update_account(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Json(req): Json<UpdateProfileRequest>,

View file

@ -126,16 +126,21 @@ impl AppointmentRepository {
Ok(appointment)
}
/// List a user's appointments, optionally filtered by top-level `status`.
/// 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 {

View file

@ -15,6 +15,9 @@ pub struct HealthStatistic {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
pub user_id: String,
/// Which profile this stat belongs to (plaintext, filterable).
#[serde(rename = "profileId", default)]
pub profile_id: String,
/// Opaque client-encrypted blob (value, unit, type, notes inside).
#[serde(rename = "encryptedData")]
pub encrypted_data: EncryptedFieldWire,
@ -27,6 +30,7 @@ pub struct HealthStatistic {
pub struct HealthStatResponse {
pub id: String,
pub user_id: String,
pub profile_id: String,
pub encrypted_data: EncryptedFieldWire,
pub recorded_at: String,
}
@ -36,6 +40,7 @@ impl From<HealthStatistic> for HealthStatResponse {
HealthStatResponse {
id: s.id.map(|o| o.to_hex()).unwrap_or_default(),
user_id: s.user_id,
profile_id: s.profile_id,
encrypted_data: s.encrypted_data,
recorded_at: s.recorded_at,
}
@ -70,6 +75,21 @@ impl HealthStatisticsRepository {
cursor.try_collect().await
}
/// All stats for a user, optionally filtered by profile_id at the Mongo
/// level (real top-level document field).
pub async fn find_by_user_filtered(
&self,
user_id: &str,
profile_id: Option<&str>,
) -> Result<Vec<HealthStatistic>, MongoError> {
let mut filter = doc! { "user_id": user_id };
if let Some(pid) = profile_id {
filter.insert("profileId", pid);
}
let cursor = self.collection.find(filter, None).await?;
cursor.try_collect().await
}
pub async fn find_by_id(&self, id: &ObjectId) -> Result<Option<HealthStatistic>, MongoError> {
let filter = doc! { "_id": id };
self.collection.find_one(filter, None).await

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