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.
This commit is contained in:
parent
015a99a7fe
commit
04520539aa
25 changed files with 1875 additions and 953 deletions
|
|
@ -5,8 +5,6 @@ use std::time::Duration;
|
|||
|
||||
use crate::models::{
|
||||
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
|
||||
permission::Permission,
|
||||
share::{Share, ShareRepository},
|
||||
user::{User, UserRepository},
|
||||
};
|
||||
|
||||
|
|
@ -14,7 +12,6 @@ use crate::models::{
|
|||
pub struct MongoDb {
|
||||
database: Database,
|
||||
pub users: Collection<User>,
|
||||
pub shares: Collection<Share>,
|
||||
pub medications: Collection<Medication>,
|
||||
pub medication_doses: Collection<MedicationDose>,
|
||||
}
|
||||
|
|
@ -62,7 +59,6 @@ impl MongoDb {
|
|||
|
||||
return Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -97,7 +93,6 @@ impl MongoDb {
|
|||
|
||||
return Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -113,7 +108,6 @@ impl MongoDb {
|
|||
|
||||
Ok(Self {
|
||||
users: database.collection("users"),
|
||||
shares: database.collection("shares"),
|
||||
medications: database.collection("medications"),
|
||||
medication_doses: database.collection("medication_doses"),
|
||||
database,
|
||||
|
|
@ -177,100 +171,6 @@ impl MongoDb {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Share Methods =====
|
||||
|
||||
pub async fn create_share(&self, share: &Share) -> Result<Option<ObjectId>> {
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
Ok(repo.create(share).await?)
|
||||
}
|
||||
|
||||
pub async fn get_share(&self, id: &str) -> Result<Option<Share>> {
|
||||
let object_id = ObjectId::parse_str(id)?;
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
Ok(repo.find_by_id(&object_id).await?)
|
||||
}
|
||||
|
||||
pub async fn list_shares_for_user(&self, user_id: &str) -> Result<Vec<Share>> {
|
||||
let object_id = ObjectId::parse_str(user_id)?;
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
Ok(repo.find_by_target(&object_id).await?)
|
||||
}
|
||||
|
||||
pub async fn update_share(&self, share: &Share) -> Result<()> {
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
repo.update(share).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_share(&self, id: &str) -> Result<()> {
|
||||
let object_id = ObjectId::parse_str(id)?;
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
repo.delete(&object_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Permission Methods =====
|
||||
|
||||
pub async fn check_user_permission(
|
||||
&self,
|
||||
user_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<bool> {
|
||||
let user_oid = ObjectId::parse_str(user_id)?;
|
||||
let resource_oid = ObjectId::parse_str(resource_id)?;
|
||||
|
||||
let repo = ShareRepository::new(self.shares.clone());
|
||||
let shares = repo.find_by_target(&user_oid).await?;
|
||||
|
||||
for share in shares {
|
||||
if share.resource_type == resource_type
|
||||
&& share.resource_id.as_ref() == Some(&resource_oid)
|
||||
&& share.active
|
||||
&& !share.is_expired()
|
||||
{
|
||||
// Check if share has the required permission
|
||||
let perm = match permission.to_lowercase().as_str() {
|
||||
"read" => Permission::Read,
|
||||
"write" => Permission::Write,
|
||||
"delete" => Permission::Delete,
|
||||
"share" => Permission::Share,
|
||||
"admin" => Permission::Admin,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
|
||||
if share.has_permission(&perm) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check permission using a simplified interface
|
||||
pub async fn check_permission(
|
||||
&self,
|
||||
user_id: &str,
|
||||
resource_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<bool> {
|
||||
// For now, check all resource types
|
||||
let resource_types = ["profiles", "health_data", "lab_results", "medications"];
|
||||
|
||||
for resource_type in resource_types {
|
||||
if self
|
||||
.check_user_permission(user_id, resource_type, resource_id, permission)
|
||||
.await?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// ===== Medication Methods (Fixed for Phase 2.8) =====
|
||||
|
||||
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue