normogen/backend/src/handlers/profile_share.rs
goose 04520539aa
Some checks failed
Lint and Build / format (pull_request) Successful in 35s
Lint and Build / clippy (pull_request) Successful in 1m41s
Lint and Build / build (pull_request) Successful in 3m51s
Lint and Build / test (pull_request) Failing after 3m41s
feat: profile sharing via X25519 envelope (Phase B, #3)
An owner can now share a profile with another account; the recipient
reads the profile's metadata AND its data (medications, appointments,
health stats) under their own login. The server stays a blind store:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.

Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
  (find_for_recipient, find_for_profile, find_active [checks active +
  expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
  recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
  DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
  GET /users/public-key (public), and authorize_profile_read — the
  share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
  and health stats: when profile_id is specified, resolve the owner via
  the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
  permission}.rs, handlers/{shares,permissions}.rs, middleware/
  permission.rs (dead), the shares collection field + methods in
  mongodb_impl.rs, the shares index, and the 5 /api/shares +
  /api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
  isolation, share-to-self/nonexistent/keyless rejections, expired
  share treated as absent.

Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
  unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
  identity private key), shareProfile, revokeShare, loadProfileShares.
  Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
  email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).

Verification: backend cargo build/clippy (-D warnings)/fmt clean, tests
compile (integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 31/31 tests pass.

⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.

Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
2026-07-19 07:21:11 -03:00

513 lines
18 KiB
Rust

//! Profile sharing (Phase B).
//!
//! Implements the X25519 envelope: an owner wraps a profile DEK to a
//! recipient's identity public key via ECDH, storing the opaque wrapped blob
//! keyed by (profileId, recipientUserId). Recipients unwrap with their own
//! identity private key. The server never sees the profile DEK.
//!
//! Also provides `authorize_profile_read` — the share-gate used by the data
//! handlers (medications, appointments, health stats) to admit recipients.
use axum::{
extract::{Extension, Path, Query, State},
http::StatusCode,
Json,
};
use mongodb::bson::{oid::ObjectId, DateTime};
use serde::{Deserialize, Serialize};
use crate::{
auth::jwt::Claims,
config::AppState,
models::profile::ProfileRepository,
models::profile_share::{ProfileShare, ProfileShareRepository},
};
fn profile_repo(state: &AppState) -> ProfileRepository {
ProfileRepository::new(state.db.get_database().collection("profiles"))
}
fn share_repo(state: &AppState) -> ProfileShareRepository {
ProfileShareRepository::new(state.db.get_database().collection("profile_shares"))
}
/// The shared profile as seen by the recipient — the profile's metadata
/// (display name blob, kind, relationship) plus the per-share ECDH-wrapped DEK
/// the recipient unwraps with their identity private key. The server returns
/// the wrapped DEK from the share record verbatim and cannot read it.
#[derive(Debug, Serialize)]
pub struct SharedProfileResponse {
pub profile_id: String,
pub owner_account_id: String,
pub name_data: String,
pub name_iv: String,
pub kind: String,
pub relationship: String,
/// Per-share ephemeral X25519 public key (base64 raw). The recipient
/// combines it with their identity private key to derive the wrap key.
pub ephemeral_public_key: String,
/// Profile DEK wrapped under the ECDH-derived key (opaque).
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
pub permissions: Vec<String>,
pub created_at: DateTime,
}
/// A share as listed by the owner (no wrapped DEK on this view — it's only
/// useful to the recipient; the owner already has the profile DEK directly).
#[derive(Debug, Serialize)]
pub struct ShareListingResponse {
pub profile_id: String,
pub recipient_user_id: String,
pub recipient_email: String,
pub permissions: Vec<String>,
pub created_at: DateTime,
pub active: bool,
}
// ---------------------------------------------------------------------------
// Share-gate: resolve the owner userId for a profile the caller may read.
// Used by the data handlers to admit recipients.
// ---------------------------------------------------------------------------
/// If the caller (claims.sub) owns the profile, returns Ok(owner_user_id).
/// Otherwise checks for an active share; if present, returns Ok(owner_user_id)
/// (the profile's owner — recipients read "as the owner" for that profile).
/// Returns Err(404 response) if the caller has no read access.
pub async fn authorize_profile_read(
state: &AppState,
claims: &Claims,
profile_id: &str,
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
let profiles = profile_repo(state);
// Owner path.
if let Ok(Some(owned)) = profiles
.find_by_profile_id_owned(profile_id, &claims.sub)
.await
{
return Ok(owned.owner_account_id);
}
// Recipient path: an active (unexpired, not-revoked) share for this pair.
let shares = share_repo(state);
match shares.find_active(profile_id, &claims.sub).await {
Ok(Some(share)) => Ok(share.owner_user_id),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
)),
Err(e) => {
tracing::error!("Share lookup failed for {}: {}", profile_id, e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// ---------------------------------------------------------------------------
// POST /api/profiles/:id/shares — owner shares a profile to a recipient.
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct CreateShareRequest {
/// The recipient, identified by email.
pub recipient_email: String,
/// Ephemeral X25519 public key (base64 raw) generated by the owner's
/// client for this share.
pub ephemeral_public_key: String,
/// Profile DEK wrapped (client-side, via ECDH) to the recipient. Opaque.
pub wrapped_profile_dek: String,
pub wrapped_profile_dek_iv: String,
#[serde(default = "default_read_permissions")]
pub permissions: Vec<String>,
/// Optional RFC-3339 expiry; null/absent = never expires.
pub expires_at: Option<String>,
}
fn default_read_permissions() -> Vec<String> {
vec!["read".to_string()]
}
pub async fn create_share(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<CreateShareRequest>,
) -> Result<(StatusCode, Json<ShareListingResponse>), (StatusCode, Json<serde_json::Value>)> {
// Verify the caller owns the profile.
let profiles = profile_repo(&state);
let owner = match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(p)) => p.owner_account_id,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("Profile lookup in create_share failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Resolve recipient by email.
let recipient = match state.db.find_user_by_email(&req.recipient_email).await {
Ok(Some(u)) => u,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "recipient not found" })),
));
}
Err(e) => {
tracing::error!("Recipient lookup failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
let recipient_id = match recipient.id {
Some(id) => id.to_string(),
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "invalid recipient state" })),
));
}
};
// Can't share to an account without an identity public key (pre-A1).
if recipient
.identity_public_key
.as_deref()
.unwrap_or("")
.is_empty()
{
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "recipient has no identity public key (account predates the keypair feature)"
})),
));
}
// Don't share to yourself.
if recipient_id == claims.sub {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "cannot share a profile with yourself" })),
));
}
let expires_at = match req.expires_at.as_deref() {
Some(s) => match DateTime::parse_rfc3339_str(s) {
Ok(dt) => Some(dt),
Err(_) => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "invalid expires_at (expected RFC 3339)" })),
));
}
},
None => None,
};
let now = DateTime::now();
let share = ProfileShare {
id: None,
profile_id: profile_id.clone(),
owner_user_id: owner.clone(),
recipient_user_id: recipient_id.clone(),
ephemeral_public_key: req.ephemeral_public_key,
wrapped_profile_dek: req.wrapped_profile_dek,
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
permissions: req.permissions.clone(),
expires_at,
created_at: now,
active: true,
};
let shares = share_repo(&state);
if let Err(e) = shares.upsert(&share).await {
tracing::error!("Share create failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
Ok((
StatusCode::CREATED,
Json(ShareListingResponse {
profile_id,
recipient_user_id: recipient_id,
recipient_email: req.recipient_email,
permissions: req.permissions,
created_at: now,
active: true,
}),
))
}
// ---------------------------------------------------------------------------
// GET /api/profiles/:id/shares — owner lists who they've shared a profile with.
// ---------------------------------------------------------------------------
pub async fn list_shares(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
) -> Result<Json<Vec<ShareListingResponse>>, (StatusCode, Json<serde_json::Value>)> {
// Owner-only: confirm ownership.
let profiles = profile_repo(&state);
match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(_)) => {}
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("Profile lookup in list_shares failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
let shares = share_repo(&state);
let rows = match shares.find_for_profile(&profile_id).await {
Ok(rows) => rows,
Err(e) => {
tracing::error!("Share list failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Resolve each recipient's email for display.
let mut out = Vec::with_capacity(rows.len());
for s in rows {
let email = match ObjectId::parse_str(&s.recipient_user_id) {
Ok(oid) => match state.db.find_user_by_id(&oid).await {
Ok(Some(u)) => u.email,
_ => String::new(),
},
Err(_) => String::new(),
};
out.push(ShareListingResponse {
profile_id: s.profile_id,
recipient_user_id: s.recipient_user_id,
recipient_email: email,
permissions: s.permissions,
created_at: s.created_at,
active: s.active,
});
}
Ok(Json(out))
}
// ---------------------------------------------------------------------------
// DELETE /api/profiles/:id/shares/:recipient — soft revoke (hard delete).
// ---------------------------------------------------------------------------
pub async fn delete_share(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path((profile_id, recipient_user_id)): Path<(String, String)>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
// Owner-only: confirm ownership.
let profiles = profile_repo(&state);
match profiles
.find_by_profile_id_owned(&profile_id, &claims.sub)
.await
{
Ok(Some(_)) => {}
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
));
}
Err(e) => {
tracing::error!("Profile lookup in delete_share failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
}
let shares = share_repo(&state);
match shares.delete(&profile_id, &recipient_user_id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "share not found" })),
)),
Err(e) => {
tracing::error!("Share delete failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// ---------------------------------------------------------------------------
// GET /api/profiles/shared-with-me — recipient lists profiles shared with them.
// ---------------------------------------------------------------------------
pub async fn list_shared_with_me(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> Result<Json<Vec<SharedProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
let shares = share_repo(&state);
let my_shares = match shares.find_for_recipient(&claims.sub).await {
Ok(rows) => rows,
Err(e) => {
tracing::error!("shared-with-me list failed: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
));
}
};
// Join each share with its profile's metadata. Only return shares for
// profiles that still exist and are still active/unexpired (find_active
// semantics, re-checked here to avoid listing a just-expired share).
let profiles = profile_repo(&state);
let mut out = Vec::with_capacity(my_shares.len());
for s in my_shares {
// Skip expired/inactive — find_for_recipient returns all rows; filter
// to currently-usable ones for the recipient's view.
if !s.active {
continue;
}
if let Some(exp) = s.expires_at {
if exp <= DateTime::now() {
continue;
}
}
let profile = match profiles.find_by_profile_id(&s.profile_id).await {
Ok(Some(p)) => p,
_ => continue, // profile deleted; skip silently
};
out.push(SharedProfileResponse {
profile_id: profile.profile_id,
owner_account_id: profile.owner_account_id,
name_data: profile.name,
name_iv: profile.name_iv,
kind: profile.kind,
relationship: profile.relationship,
ephemeral_public_key: s.ephemeral_public_key,
wrapped_profile_dek: s.wrapped_profile_dek,
wrapped_profile_dek_iv: s.wrapped_profile_dek_iv,
permissions: s.permissions,
created_at: s.created_at,
});
}
Ok(Json(out))
}
// ---------------------------------------------------------------------------
// GET /api/profiles/:id — extend the existing profile GET to admit recipients.
// This stands in for "the recipient can fetch one shared profile's metadata".
// Implemented here so the share-aware logic lives with the share code.
// ---------------------------------------------------------------------------
/// Fetch a single profile's metadata, admitting both owner and share-recipient.
/// Returns the owner's-view `ProfileResponse` shape (the wrapped profile DEK
/// there is account-wrapped — recipients ignore it and use the share's
/// ECDH-wrapped DEK from /profiles/shared-with-me instead).
pub async fn get_profile_shared_aware(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
// authorize_profile_read admits owner or active-share recipient.
let _owner = authorize_profile_read(&state, &claims, &profile_id).await?;
let profiles = profile_repo(&state);
match profiles.find_by_profile_id(&profile_id).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 fetch failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "database error" })),
))
}
}
}
// Re-export ProfileResponse from the profile handlers for the shared-aware GET.
pub use crate::handlers::profile::ProfileResponse;
// ---------------------------------------------------------------------------
// GET /api/users/public-key — look up a recipient's identity public key.
// Public (no auth) — public keys are not secret.
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct PublicKeyQuery {
pub email: String,
}
#[derive(Debug, Serialize)]
pub struct PublicKeyResponse {
pub user_id: String,
pub identity_public_key: String,
}
pub async fn get_public_key(
State(state): State<AppState>,
Query(q): Query<PublicKeyQuery>,
) -> Result<Json<PublicKeyResponse>, (StatusCode, Json<serde_json::Value>)> {
let user = match state.db.find_user_by_email(&q.email).await {
Ok(Some(u)) => u,
Ok(None) | Err(_) => {
// Don't leak whether the email exists.
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "no public key for this address" })),
));
}
};
let user_id = user
.id
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "invalid user state" })),
))?
.to_string();
let pk = user.identity_public_key.unwrap_or_default();
if pk.is_empty() {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "no public key for this address" })),
));
}
Ok(Json(PublicKeyResponse {
user_id,
identity_public_key: pk,
}))
}