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
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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