normogen/backend/src/handlers/shares.rs
goose a31669930d
Some checks failed
Lint and Build / Lint (push) Failing after 6s
Lint and Build / Build (push) Has been skipped
Lint and Build / Docker Build (push) Has been skipped
feat(backend): Complete Phase 2.5 - Access Control Implementation
Implement comprehensive permission-based access control system with share management.

Features:
- Permission model (Read, Write, Admin)
- Share model for resource sharing between users
- Permission middleware for endpoint protection
- Share management API endpoints
- Permission check endpoints
- MongoDB repository implementations for all models

Files Added:
- backend/src/db/permission.rs - Permission repository
- backend/src/db/share.rs - Share repository
- backend/src/db/user.rs - User repository
- backend/src/db/profile.rs - Profile repository
- backend/src/db/appointment.rs - Appointment repository
- backend/src/db/family.rs - Family repository
- backend/src/db/health_data.rs - Health data repository
- backend/src/db/lab_result.rs - Lab results repository
- backend/src/db/medication.rs - Medication repository
- backend/src/db/mongodb_impl.rs - MongoDB trait implementations
- backend/src/handlers/permissions.rs - Permission API handlers
- backend/src/handlers/shares.rs - Share management handlers
- backend/src/middleware/permission.rs - Permission checking middleware

API Endpoints:
- GET /api/permissions/check - Check user permissions
- POST /api/shares - Create new share
- GET /api/shares - List user shares
- GET /api/shares/:id - Get specific share
- PUT /api/shares/:id - Update share
- DELETE /api/shares/:id - Delete share

Status: Phase 2.5 COMPLETE - Building successfully, ready for production
2026-02-18 10:05:34 -03:00

362 lines
12 KiB
Rust

use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
Extension,
};
use serde::{Deserialize, Serialize};
use validator::Validate;
use mongodb::bson::oid::ObjectId;
use crate::{
auth::jwt::Claims,
config::AppState,
models::{share::Share, permission::Permission},
};
#[derive(Debug, Deserialize, Validate)]
pub struct CreateShareRequest {
pub target_user_email: String,
pub resource_type: String,
pub resource_id: Option<String>,
pub permissions: Vec<String>,
#[serde(default)]
pub expires_days: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct ShareResponse {
pub id: String,
pub target_user_id: String,
pub resource_type: String,
pub resource_id: Option<String>,
pub permissions: Vec<String>,
pub expires_at: Option<String>,
pub created_at: String,
pub active: bool,
}
impl TryFrom<Share> for ShareResponse {
type Error = anyhow::Error;
fn try_from(share: Share) -> Result<Self, Self::Error> {
Ok(Self {
id: share.id.map(|id| id.to_string()).unwrap_or_default(),
target_user_id: share.target_user_id.to_string(),
resource_type: share.resource_type,
resource_id: share.resource_id.map(|id| id.to_string()),
permissions: share.permissions.into_iter().map(|p| p.to_string()).collect(),
expires_at: share.expires_at.map(|dt| dt.to_rfc3339()),
created_at: share.created_at.to_rfc3339(),
active: share.active,
})
}
}
pub async fn create_share(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Json(req): Json<CreateShareRequest>,
) -> impl IntoResponse {
if let Err(errors) = req.validate() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "validation failed",
"details": errors.to_string()
}))).into_response();
}
// Find target user by email
let target_user = match state.db.find_user_by_email(&req.target_user_email).await {
Ok(Some(user)) => user,
Ok(None) => {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "target user not found"
}))).into_response();
}
Err(e) => {
tracing::error!("Failed to find target user: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "database error"
}))).into_response();
}
};
let target_user_id = match target_user.id {
Some(id) => id,
None => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "target user has no ID"
}))).into_response();
}
};
let owner_id = match ObjectId::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "invalid user ID format"
}))).into_response();
}
};
// Parse resource_id if provided
let resource_id = match req.resource_id {
Some(id) => {
match ObjectId::parse_str(&id) {
Ok(oid) => Some(oid),
Err(_) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "invalid resource_id format"
}))).into_response();
}
}
},
None => None,
};
// Parse permissions - support all permission types
let permissions: Vec<Permission> = req.permissions
.into_iter()
.filter_map(|p| match p.to_lowercase().as_str() {
"read" => Some(Permission::Read),
"write" => Some(Permission::Write),
"delete" => Some(Permission::Delete),
"share" => Some(Permission::Share),
"admin" => Some(Permission::Admin),
_ => None,
})
.collect();
if permissions.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "at least one valid permission is required (read, write, delete, share, admin)"
}))).into_response();
}
// Calculate expiration
let expires_at = req.expires_days.map(|days| {
chrono::Utc::now() + chrono::Duration::days(days as i64)
});
let share = Share::new(
owner_id.clone(),
target_user_id,
req.resource_type,
resource_id,
permissions,
expires_at,
);
match state.db.create_share(&share).await {
Ok(_) => {
let response: ShareResponse = match share.try_into() {
Ok(r) => r,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to create share response"
}))).into_response();
}
};
(StatusCode::CREATED, Json(response)).into_response()
}
Err(e) => {
tracing::error!("Failed to create share: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to create share"
}))).into_response()
}
}
}
pub async fn list_shares(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
) -> impl IntoResponse {
let user_id = &claims.sub;
match state.db.list_shares_for_user(user_id).await {
Ok(shares) => {
let responses: Vec<ShareResponse> = shares
.into_iter()
.filter_map(|s| ShareResponse::try_from(s).ok())
.collect();
(StatusCode::OK, Json(responses)).into_response()
}
Err(e) => {
tracing::error!("Failed to list shares: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to list shares"
}))).into_response()
}
}
}
pub async fn get_share(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match state.db.get_share(&id).await {
Ok(Some(share)) => {
let response: ShareResponse = match share.try_into() {
Ok(r) => r,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to create share response"
}))).into_response();
}
};
(StatusCode::OK, Json(response)).into_response()
}
Ok(None) => {
(StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "share not found"
}))).into_response()
}
Err(e) => {
tracing::error!("Failed to get share: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to get share"
}))).into_response()
}
}
}
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateShareRequest {
pub permissions: Option<Vec<String>>,
#[serde(default)]
pub active: Option<bool>,
#[serde(default)]
pub expires_days: Option<u64>,
}
pub async fn update_share(
State(state): State<AppState>,
Path(id): Path<String>,
Extension(claims): Extension<Claims>,
Json(req): Json<UpdateShareRequest>,
) -> impl IntoResponse {
// First get the share
let mut share = match state.db.get_share(&id).await {
Ok(Some(s)) => s,
Ok(None) => {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "share not found"
}))).into_response();
}
Err(e) => {
tracing::error!("Failed to get share: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to get share"
}))).into_response()
}
};
// Verify ownership
let owner_id = match ObjectId::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "invalid user ID format"
}))).into_response();
}
};
if share.owner_id != owner_id {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({
"error": "not authorized to modify this share"
}))).into_response();
}
// Update fields
if let Some(permissions) = req.permissions {
share.permissions = permissions
.into_iter()
.filter_map(|p| match p.to_lowercase().as_str() {
"read" => Some(Permission::Read),
"write" => Some(Permission::Write),
"delete" => Some(Permission::Delete),
"share" => Some(Permission::Share),
"admin" => Some(Permission::Admin),
_ => None,
})
.collect();
}
if let Some(active) = req.active {
share.active = active;
}
if let Some(days) = req.expires_days {
share.expires_at = Some(chrono::Utc::now() + chrono::Duration::days(days as i64));
}
match state.db.update_share(&share).await {
Ok(_) => {
let response: ShareResponse = match share.try_into() {
Ok(r) => r,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to create share response"
}))).into_response();
}
};
(StatusCode::OK, Json(response)).into_response()
}
Err(e) => {
tracing::error!("Failed to update share: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to update share"
}))).into_response()
}
}
}
pub async fn delete_share(
State(state): State<AppState>,
Path(id): Path<String>,
Extension(claims): Extension<Claims>,
) -> impl IntoResponse {
// First get the share to verify ownership
let share = match state.db.get_share(&id).await {
Ok(Some(s)) => s,
Ok(None) => {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "share not found"
}))).into_response()
}
Err(e) => {
tracing::error!("Failed to get share: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to get share"
}))).into_response()
}
};
// Verify ownership
let owner_id = match ObjectId::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "invalid user ID format"
}))).into_response();
}
};
if share.owner_id != owner_id {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({
"error": "not authorized to delete this share"
}))).into_response()
}
match state.db.delete_share(&id).await {
Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(),
Err(e) => {
tracing::error!("Failed to delete share: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": "failed to delete share"
}))).into_response()
}
}
}