diff --git a/entity/Cargo.toml b/entity/Cargo.toml index ce2a272..ff60122 100644 --- a/entity/Cargo.toml +++ b/entity/Cargo.toml @@ -14,7 +14,8 @@ serde = { version = "1.0.210", features = ["derive"] } sqlx = { version = "0.8.2", features = ["time", "runtime-tokio"] } sqlx-sqlite = { version = "0.8.2" } utoipa = { version = "4.2.0", features = ["axum_extras", "uuid"] } -uuid = { version = "1.11.0", features = ["v4"] } + +uuid = { version = "1.11.0", features = ["v4", "serde"] } [dependencies.sea-orm] version = "1.1.0" diff --git a/entity_api/src/coaching_relationship.rs b/entity_api/src/coaching_relationship.rs index 5938dc2..b281ce2 100644 --- a/entity_api/src/coaching_relationship.rs +++ b/entity_api/src/coaching_relationship.rs @@ -37,6 +37,10 @@ pub async fn create( Ok(coaching_relationship_active_model.insert(db).await?) } +pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result, Error> { + Ok(Entity::find_by_id(id).one(db).await?) +} + pub async fn find_by_user(db: &DatabaseConnection, user_id: Id) -> Result, Error> { let coaching_relationships: Vec = coaching_relationships::Entity::find() @@ -233,6 +237,28 @@ mod tests { use super::*; use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + #[tokio::test] + async fn find_by_id_returns_record_when_present() -> Result<(), Error> { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + + let coaching_relationship_id = Id::new_v4(); + let _ = find_by_id(&db, coaching_relationship_id).await; + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "coaching_relationships"."id", "coaching_relationships"."organization_id", "coaching_relationships"."coach_id", "coaching_relationships"."coachee_id", "coaching_relationships"."created_at", "coaching_relationships"."updated_at" FROM "refactor_platform"."coaching_relationships" WHERE "coaching_relationships"."id" = $1 LIMIT $2"#, + [ + coaching_relationship_id.into(), + sea_orm::Value::BigUnsigned(Some(1)) + ] + )] + ); + + Ok(()) + } + #[tokio::test] async fn find_by_user_returns_all_records_associated_with_user() -> Result<(), Error> { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); diff --git a/entity_api/src/coaching_session.rs b/entity_api/src/coaching_session.rs index c26d1b5..4045b46 100644 --- a/entity_api/src/coaching_session.rs +++ b/entity_api/src/coaching_session.rs @@ -1,6 +1,10 @@ use super::error::{EntityApiErrorCode, Error}; use crate::{naive_date_parse_str, uuid_parse_str}; -use entity::coaching_sessions::{self, ActiveModel, Entity, Model}; +use entity::{ + coaching_relationships, + coaching_sessions::{self, ActiveModel, Entity, Model}, + Id, +}; use log::debug; use sea_orm::{entity::prelude::*, DatabaseConnection, Set, TryIntoModel}; use std::collections::HashMap; @@ -31,6 +35,29 @@ pub async fn create( .try_into_model()?) } +pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result, Error> { + Ok(Entity::find_by_id(id).one(db).await?) +} + +pub async fn find_by_id_with_coaching_relationship( + db: &DatabaseConnection, + id: Id, +) -> Result<(Model, coaching_relationships::Model), Error> { + if let Some(results) = Entity::find_by_id(id) + .find_also_related(coaching_relationships::Entity) + .one(db) + .await? + { + if let Some(coaching_relationship) = results.1 { + return Ok((results.0, coaching_relationship)); + } + } + Err(Error { + inner: None, + error_code: EntityApiErrorCode::RecordNotFound, + }) +} + pub async fn find_by( db: &DatabaseConnection, params: HashMap, @@ -100,6 +127,50 @@ mod tests { Ok(()) } + #[tokio::test] + async fn find_by_id_returns_a_single_record() -> Result<(), Error> { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + + let coaching_session_id = Id::new_v4(); + let _ = find_by_id(&db, coaching_session_id).await; + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "coaching_sessions"."id", "coaching_sessions"."coaching_relationship_id", "coaching_sessions"."date", "coaching_sessions"."timezone", "coaching_sessions"."created_at", "coaching_sessions"."updated_at" FROM "refactor_platform"."coaching_sessions" WHERE "coaching_sessions"."id" = $1 LIMIT $2"#, + [ + coaching_session_id.into(), + sea_orm::Value::BigUnsigned(Some(1)) + ] + )] + ); + + Ok(()) + } + + #[tokio::test] + async fn find_by_id_with_coaching_relationship_returns_a_single_record() -> Result<(), Error> { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + + let coaching_session_id = Id::new_v4(); + let _ = find_by_id_with_coaching_relationship(&db, coaching_session_id).await; + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "coaching_sessions"."id" AS "A_id", "coaching_sessions"."coaching_relationship_id" AS "A_coaching_relationship_id", "coaching_sessions"."date" AS "A_date", "coaching_sessions"."timezone" AS "A_timezone", "coaching_sessions"."created_at" AS "A_created_at", "coaching_sessions"."updated_at" AS "A_updated_at", "coaching_relationships"."id" AS "B_id", "coaching_relationships"."organization_id" AS "B_organization_id", "coaching_relationships"."coach_id" AS "B_coach_id", "coaching_relationships"."coachee_id" AS "B_coachee_id", "coaching_relationships"."created_at" AS "B_created_at", "coaching_relationships"."updated_at" AS "B_updated_at" FROM "refactor_platform"."coaching_sessions" LEFT JOIN "refactor_platform"."coaching_relationships" ON "coaching_sessions"."coaching_relationship_id" = "coaching_relationships"."id" WHERE "coaching_sessions"."id" = $1 LIMIT $2"#, + [ + coaching_session_id.into(), + sea_orm::Value::BigUnsigned(Some(1)) + ] + )] + ); + + Ok(()) + } + #[tokio::test] async fn find_by_coaching_relationships_returns_all_records_associated_with_coaching_relationship( ) -> Result<(), Error> { diff --git a/migration/src/refactor_platform_rs.sql b/migration/src/refactor_platform_rs.sql index 96882ce..37a08b2 100644 --- a/migration/src/refactor_platform_rs.sql +++ b/migration/src/refactor_platform_rs.sql @@ -1,6 +1,6 @@ --- SQL dump generated using DBML (dbml-lang.org) +-- SQL dump generated using DBML (dbml.dbdiagram.io) -- Database: PostgreSQL --- Generated at: 2024-12-16T05:18:20.372Z +-- Generated at: 2025-01-04T15:25:10.725Z CREATE TYPE "refactor_platform"."status" AS ENUM ( diff --git a/web/src/controller/organization_controller.rs b/web/src/controller/organization_controller.rs index 97862ac..0b4907e 100644 --- a/web/src/controller/organization_controller.rs +++ b/web/src/controller/organization_controller.rs @@ -35,14 +35,16 @@ use log::debug; )] pub async fn index( CompareApiVersion(_v): CompareApiVersion, - AuthenticatedUser(_user): AuthenticatedUser, + AuthenticatedUser(user): AuthenticatedUser, // TODO: create a new Extractor to authorize the user to access // the data requested State(app_state): State, - Query(params): Query>, + Query(mut params): Query>, ) -> Result { debug!("GET all Organizations"); + params.insert("user_id".to_string(), user.id.to_string()); + let organizations = OrganizationApi::find_by(app_state.db_conn_ref(), params).await?; debug!("Found Organizations: {:?}", organizations); diff --git a/web/src/lib.rs b/web/src/lib.rs index 2dede74..04285cf 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -22,6 +22,7 @@ use tower_http::cors::CorsLayer; mod controller; mod error; pub(crate) mod extractors; +pub(crate) mod protect; mod router; pub async fn init_server(app_state: AppState) -> Result<()> { diff --git a/web/src/protect/actions.rs b/web/src/protect/actions.rs new file mode 100644 index 0000000..057d922 --- /dev/null +++ b/web/src/protect/actions.rs @@ -0,0 +1,51 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Query, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use entity::Id; +use entity_api::coaching_session; +use log::*; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct QueryParams { + coaching_session_id: Id, +} + +/// Checks that coaching relationship record associated with the coaching session +/// referenced by `coaching_session_id exists and that the authenticated user is associated with it. +/// Intended to be given to axum::middleware::from_fn_with_state in the router +pub(crate) async fn index( + State(app_state): State, + AuthenticatedUser(user): AuthenticatedUser, + Query(params): Query, + request: Request, + next: Next, +) -> impl IntoResponse { + match coaching_session::find_by_id_with_coaching_relationship( + app_state.db_conn_ref(), + params.coaching_session_id, + ) + .await + { + Ok((_coaching_session, coaching_relationship)) => { + if coaching_relationship.coach_id == user.id + || coaching_relationship.coachee_id == user.id + { + // User has access to coaching relationship + next.run(request).await + } else { + // User does not have access to coaching relationship + (StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response() + } + } + Err(e) => { + error!("Error authorizing overarching goals index{:?}", e); + + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() + } + } +} diff --git a/web/src/protect/agreements.rs b/web/src/protect/agreements.rs new file mode 100644 index 0000000..057d922 --- /dev/null +++ b/web/src/protect/agreements.rs @@ -0,0 +1,51 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Query, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use entity::Id; +use entity_api::coaching_session; +use log::*; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct QueryParams { + coaching_session_id: Id, +} + +/// Checks that coaching relationship record associated with the coaching session +/// referenced by `coaching_session_id exists and that the authenticated user is associated with it. +/// Intended to be given to axum::middleware::from_fn_with_state in the router +pub(crate) async fn index( + State(app_state): State, + AuthenticatedUser(user): AuthenticatedUser, + Query(params): Query, + request: Request, + next: Next, +) -> impl IntoResponse { + match coaching_session::find_by_id_with_coaching_relationship( + app_state.db_conn_ref(), + params.coaching_session_id, + ) + .await + { + Ok((_coaching_session, coaching_relationship)) => { + if coaching_relationship.coach_id == user.id + || coaching_relationship.coachee_id == user.id + { + // User has access to coaching relationship + next.run(request).await + } else { + // User does not have access to coaching relationship + (StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response() + } + } + Err(e) => { + error!("Error authorizing overarching goals index{:?}", e); + + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() + } + } +} diff --git a/web/src/protect/coaching_relationships.rs b/web/src/protect/coaching_relationships.rs new file mode 100644 index 0000000..57d078a --- /dev/null +++ b/web/src/protect/coaching_relationships.rs @@ -0,0 +1,34 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Path, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; + +use entity::Id; +use entity_api::organization; +use std::collections::HashSet; + +/// Checks that the organization record referenced by `organization_id` +/// exists and that the authenticated user is associated with i.t +/// Intended to be given to axum::middleware::from_fn_with_state in the router +pub(crate) async fn index( + State(app_state): State, + AuthenticatedUser(user): AuthenticatedUser, + Path(organization_id): Path, + request: Request, + next: Next, +) -> impl IntoResponse { + let user_organization_ids = organization::find_by_user(app_state.db_conn_ref(), user.id) + .await + .unwrap_or(vec![]) + .into_iter() + .map(|org| org.id) + .collect::>(); + if user_organization_ids.contains(&organization_id) { + next.run(request).await + } else { + (StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response() + } +} diff --git a/web/src/protect/coaching_sessions.rs b/web/src/protect/coaching_sessions.rs new file mode 100644 index 0000000..f27f904 --- /dev/null +++ b/web/src/protect/coaching_sessions.rs @@ -0,0 +1,47 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Query, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use serde::Deserialize; + +use entity::Id; +use entity_api::coaching_relationship; + +#[derive(Debug, Deserialize)] +pub(crate) struct QueryParams { + coaching_relationship_id: Id, +} + +/// Checks that coaching relationship record referenced by `coaching_relationship_id` +/// exists and that the authenticated user is associated with it. +/// Intended to be given to axum::middleware::from_fn_with_state in the router +pub(crate) async fn index( + State(app_state): State, + AuthenticatedUser(user): AuthenticatedUser, + Query(params): Query, + request: Request, + next: Next, +) -> impl IntoResponse { + let coaching_relationship = + coaching_relationship::find_by_id(app_state.db_conn_ref(), params.coaching_relationship_id) + .await + .unwrap_or_default(); + match coaching_relationship { + Some(coaching_relationship) => { + if coaching_relationship.coach_id == user.id + || coaching_relationship.coachee_id == user.id + { + // User has access to coaching relationship + next.run(request).await + } else { + // User does not have access to coaching relationship + (StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response() + } + } + // coaching relationship with given ID not found + None => (StatusCode::NOT_FOUND, "NOT FOUND").into_response(), + } +} diff --git a/web/src/protect/mod.rs b/web/src/protect/mod.rs new file mode 100644 index 0000000..11c495b --- /dev/null +++ b/web/src/protect/mod.rs @@ -0,0 +1,6 @@ +pub(crate) mod actions; +pub(crate) mod agreements; +pub(crate) mod coaching_relationships; +pub(crate) mod coaching_sessions; +pub(crate) mod notes; +pub(crate) mod overarching_goals; diff --git a/web/src/protect/notes.rs b/web/src/protect/notes.rs new file mode 100644 index 0000000..057d922 --- /dev/null +++ b/web/src/protect/notes.rs @@ -0,0 +1,51 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Query, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use entity::Id; +use entity_api::coaching_session; +use log::*; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct QueryParams { + coaching_session_id: Id, +} + +/// Checks that coaching relationship record associated with the coaching session +/// referenced by `coaching_session_id exists and that the authenticated user is associated with it. +/// Intended to be given to axum::middleware::from_fn_with_state in the router +pub(crate) async fn index( + State(app_state): State, + AuthenticatedUser(user): AuthenticatedUser, + Query(params): Query, + request: Request, + next: Next, +) -> impl IntoResponse { + match coaching_session::find_by_id_with_coaching_relationship( + app_state.db_conn_ref(), + params.coaching_session_id, + ) + .await + { + Ok((_coaching_session, coaching_relationship)) => { + if coaching_relationship.coach_id == user.id + || coaching_relationship.coachee_id == user.id + { + // User has access to coaching relationship + next.run(request).await + } else { + // User does not have access to coaching relationship + (StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response() + } + } + Err(e) => { + error!("Error authorizing overarching goals index{:?}", e); + + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() + } + } +} diff --git a/web/src/protect/overarching_goals.rs b/web/src/protect/overarching_goals.rs new file mode 100644 index 0000000..057d922 --- /dev/null +++ b/web/src/protect/overarching_goals.rs @@ -0,0 +1,51 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Query, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use entity::Id; +use entity_api::coaching_session; +use log::*; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct QueryParams { + coaching_session_id: Id, +} + +/// Checks that coaching relationship record associated with the coaching session +/// referenced by `coaching_session_id exists and that the authenticated user is associated with it. +/// Intended to be given to axum::middleware::from_fn_with_state in the router +pub(crate) async fn index( + State(app_state): State, + AuthenticatedUser(user): AuthenticatedUser, + Query(params): Query, + request: Request, + next: Next, +) -> impl IntoResponse { + match coaching_session::find_by_id_with_coaching_relationship( + app_state.db_conn_ref(), + params.coaching_session_id, + ) + .await + { + Ok((_coaching_session, coaching_relationship)) => { + if coaching_relationship.coach_id == user.id + || coaching_relationship.coachee_id == user.id + { + // User has access to coaching relationship + next.run(request).await + } else { + // User does not have access to coaching relationship + (StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response() + } + } + Err(e) => { + error!("Error authorizing overarching goals index{:?}", e); + + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() + } + } +} diff --git a/web/src/router.rs b/web/src/router.rs index f719520..01f4363 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -1,5 +1,6 @@ -use crate::AppState; +use crate::{protect, AppState}; use axum::{ + middleware::from_fn_with_state, routing::{delete, get, post, put}, Router, }; @@ -122,10 +123,18 @@ fn action_routes(app_state: AppState) -> Router { Router::new() .route("/actions", post(action_controller::create)) .route("/actions/:id", put(action_controller::update)) - .route("/actions", get(action_controller::index)) .route("/actions/:id", get(action_controller::read)) .route("/actions/:id/status", put(action_controller::update_status)) .route("/actions/:id", delete(action_controller::delete)) + .merge( + // GET /actions + Router::new() + .route("/actions", get(action_controller::index)) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::actions::index, + )), + ) .route_layer(login_required!(Backend, login_url = "/login")) .with_state(app_state) } @@ -134,7 +143,15 @@ fn agreement_routes(app_state: AppState) -> Router { Router::new() .route("/agreements", post(agreement_controller::create)) .route("/agreements/:id", put(agreement_controller::update)) - .route("/agreements", get(agreement_controller::index)) + .merge( + // GET /agreements + Router::new() + .route("/agreements", get(agreement_controller::index)) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::agreements::index, + )), + ) .route("/agreements/:id", get(agreement_controller::read)) .route("/agreements/:id", delete(agreement_controller::delete)) .route_layer(login_required!(Backend, login_url = "/login")) @@ -147,9 +164,17 @@ pub fn coaching_sessions_routes(app_state: AppState) -> Router { "/coaching_sessions", post(coaching_session_controller::create), ) - .route( - "/coaching_sessions", - get(coaching_session_controller::index), + .merge( + // Get /coaching_sessions + Router::new() + .route( + "/coaching_sessions", + get(coaching_session_controller::index), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::coaching_sessions::index, + )), ) .route_layer(login_required!(Backend, login_url = "/login")) .with_state(app_state) @@ -159,7 +184,12 @@ fn note_routes(app_state: AppState) -> Router { Router::new() .route("/notes", post(note_controller::create)) .route("/notes/:id", put(note_controller::update)) - .route("/notes", get(note_controller::index)) + .merge( + // GET /notes + Router::new() + .route("/notes", get(note_controller::index)) + .route_layer(from_fn_with_state(app_state.clone(), protect::notes::index)), + ) .route("/notes/:id", get(note_controller::read)) .route_layer(login_required!(Backend, login_url = "/login")) .with_state(app_state) @@ -171,9 +201,17 @@ fn organization_coaching_relationship_routes(app_state: AppState) -> Router { "/organizations/:organization_id/coaching_relationships", post(coaching_relationship_controller::create), ) - .route( - "/organizations/:organization_id/coaching_relationships", - get(organization::coaching_relationship_controller::index), + .merge( + // GET /organizations/:organization_id/coaching_relationships + Router::new() + .route( + "/organizations/:organization_id/coaching_relationships", + get(organization::coaching_relationship_controller::index), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::coaching_relationships::index, + )), ) .route( "/organizations/:organization_id/coaching_relationships/:relationship_id", @@ -211,9 +249,17 @@ pub fn overarching_goal_routes(app_state: AppState) -> Router { "/overarching_goals/:id", put(overarching_goal_controller::update), ) - .route( - "/overarching_goals", - get(overarching_goal_controller::index), + .merge( + // GET /overarching_goals + Router::new() + .route( + "/overarching_goals", + get(overarching_goal_controller::index), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::overarching_goals::index, + )), ) .route( "/overarching_goals/:id",