Skip to content

Commit d8b99b6

Browse files
calebbourgjhodapp
andauthored
Add authorization and validation checks to coaching relationship crea… (#157)
* add authorization and validation checks to coaching relationship creation * update failing test * generate coaching relationship slugs from user first_names * Update entity_api/src/coaching_relationship.rs Co-authored-by: Jim Hodapp <[email protected]> * set organization_id from path for creating Coaching Relationships --------- Co-authored-by: Jim Hodapp <[email protected]>
1 parent c57e378 commit d8b99b6

File tree

10 files changed

+173
-43
lines changed

10 files changed

+173
-43
lines changed

domain/src/user.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,19 @@ pub async fn create_user_and_coaching_relationship(
106106
let new_coaching_relationship_model = entity_api::coaching_relationships::Model {
107107
coachee_id: new_user.id,
108108
coach_id,
109-
organization_id,
110109
// These will be overridden
110+
organization_id: Default::default(),
111111
id: Default::default(),
112112
slug: "".to_string(),
113113
created_at: Utc::now().into(),
114114
updated_at: Utc::now().into(),
115115
};
116-
entity_api::coaching_relationship::create(&txn, new_coaching_relationship_model).await?;
116+
entity_api::coaching_relationship::create(
117+
&txn,
118+
organization_id,
119+
new_coaching_relationship_model,
120+
)
121+
.await?;
117122
// This is not probably the type of error we'll ultimately be exposing. Again just temporary (hopfully
118123
txn.commit().await.map_err(|e| Error {
119124
source: Some(Box::new(e)),

entity/src/coaching_relationships.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub struct Model {
1515
#[serde(skip_deserializing)]
1616
#[sea_orm(primary_key)]
1717
pub id: Id,
18+
#[serde(skip_deserializing)]
1819
#[sea_orm(unique)]
1920
pub organization_id: Id,
2021
pub coach_id: Id,

entity_api/src/coaching_relationship.rs

Lines changed: 113 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use super::error::{EntityApiErrorKind, Error};
1+
use super::{
2+
error::{EntityApiErrorKind, Error},
3+
organization,
4+
};
25
use crate::user;
36
use chrono::Utc;
47
use entity::{
@@ -16,17 +19,42 @@ use slugify::slugify;
1619

1720
pub async fn create(
1821
db: &impl ConnectionTrait,
22+
organization_id: Id,
1923
coaching_relationship_model: Model,
20-
) -> Result<Model, Error> {
24+
) -> Result<CoachingRelationshipWithUserNames, Error> {
2125
debug!(
2226
"New Coaching Relationship Model to be inserted: {:?}",
2327
coaching_relationship_model
2428
);
2529

30+
let coach = user::find_by_id(db, coaching_relationship_model.coach_id).await?;
31+
let coachee = user::find_by_id(db, coaching_relationship_model.coachee_id).await?;
32+
33+
let coach_organization_ids = organization::find_by_user(db, coach.id)
34+
.await?
35+
.iter()
36+
.map(|org| org.id)
37+
.collect::<Vec<Id>>();
38+
let coachee_organization_ids = organization::find_by_user(db, coachee.id)
39+
.await?
40+
.iter()
41+
.map(|org| org.id)
42+
.collect::<Vec<Id>>();
43+
44+
// Check that the coach and coachee belong to the correct organization
45+
if !coach_organization_ids.contains(&organization_id)
46+
|| !coachee_organization_ids.contains(&organization_id)
47+
{
48+
error!("Coach and coachee do not belong to the correct organization, not creating requested new coaching relationship between coach: {:?} and coachee: {:?} for organization: {:?}.", coaching_relationship_model.coach_id, coaching_relationship_model.coachee_id, organization_id);
49+
return Err(Error {
50+
source: None,
51+
error_kind: EntityApiErrorKind::ValidationError,
52+
});
53+
}
54+
2655
// Coaching Relationship must be unique within the context of an organization
2756
// Note: this is enforced at the database level as well
28-
let existing_coaching_relationships =
29-
find_by_organization(db, coaching_relationship_model.organization_id).await?;
57+
let existing_coaching_relationships = find_by_organization(db, organization_id).await?;
3058
let existing_coaching_relationship = existing_coaching_relationships.iter().find(|cr| {
3159
cr.coach_id == coaching_relationship_model.coach_id
3260
&& cr.coachee_id == coaching_relationship_model.coachee_id
@@ -46,15 +74,27 @@ pub async fn create(
4674
let slug = slugify!(format!("{} {}", coach.first_name, coachee.first_name).as_str());
4775

4876
let coaching_relationship_active_model: ActiveModel = ActiveModel {
49-
organization_id: Set(coaching_relationship_model.organization_id),
77+
organization_id: Set(organization_id),
5078
coach_id: Set(coaching_relationship_model.coach_id),
5179
coachee_id: Set(coaching_relationship_model.coachee_id),
5280
slug: Set(slug),
5381
created_at: Set(now.into()),
5482
updated_at: Set(now.into()),
5583
..Default::default()
5684
};
57-
Ok(coaching_relationship_active_model.insert(db).await?)
85+
let inserted: Model = coaching_relationship_active_model.insert(db).await?;
86+
87+
Ok(CoachingRelationshipWithUserNames {
88+
id: inserted.id,
89+
coach_id: inserted.coach_id,
90+
coachee_id: inserted.coachee_id,
91+
coach_first_name: coach.first_name,
92+
coach_last_name: coach.last_name,
93+
coachee_first_name: coachee.first_name,
94+
coachee_last_name: coachee.last_name,
95+
created_at: inserted.created_at,
96+
updated_at: inserted.updated_at,
97+
})
5898
}
5999

60100
pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result<Model, Error> {
@@ -206,7 +246,7 @@ pub async fn delete_by_user_id(db: &impl ConnectionTrait, user_id: Id) -> Result
206246

207247
// A convenient combined struct that holds the results of looking up the Users associated
208248
// with the coach/coachee ids. This should be used as an implementation detail only.
209-
#[derive(FromQueryResult, Debug)]
249+
#[derive(FromQueryResult, Debug, PartialEq)]
210250
pub struct CoachingRelationshipWithUserNames {
211251
pub id: Id,
212252
pub coach_id: Id,
@@ -337,26 +377,76 @@ mod tests {
337377
#[tokio::test]
338378
async fn create_returns_validation_error_for_duplicate_relationship() -> Result<(), Error> {
339379
use entity::coaching_relationships::Model;
340-
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
380+
use sea_orm::{DatabaseBackend, MockDatabase};
341381

342382
let organization_id = Id::new_v4();
343383
let coach_id = Id::new_v4();
344384
let coachee_id = Id::new_v4();
385+
let coach_organization_id = Id::new_v4();
386+
let coachee_organization_id = Id::new_v4();
387+
388+
let coach_user = entity::users::Model {
389+
id: coach_id.clone(),
390+
first_name: "Coach".to_string(),
391+
last_name: "User".to_string(),
392+
email: "[email protected]".to_string(),
393+
password: "hash".to_string(),
394+
display_name: Some("Coach User".to_string()),
395+
github_username: Some("coach_user".to_string()),
396+
role: entity::users::Role::User,
397+
github_profile_url: Some("https://github.com/coach_user".to_string()),
398+
created_at: chrono::Utc::now().into(),
399+
updated_at: chrono::Utc::now().into(),
400+
};
401+
402+
let coachee_user = entity::users::Model {
403+
id: coachee_id.clone(),
404+
first_name: "Coachee".to_string(),
405+
last_name: "User".to_string(),
406+
email: "[email protected]".to_string(),
407+
password: "hash".to_string(),
408+
display_name: Some("Coachee User".to_string()),
409+
github_username: Some("coachee_user".to_string()),
410+
role: entity::users::Role::User,
411+
github_profile_url: Some("https://github.com/coachee_user".to_string()),
412+
created_at: chrono::Utc::now().into(),
413+
updated_at: chrono::Utc::now().into(),
414+
};
415+
416+
let coaching_relationships = vec![Model {
417+
id: Id::new_v4(),
418+
organization_id: organization_id.clone(),
419+
coach_id: coach_id.clone(),
420+
coachee_id: coachee_id.clone(),
421+
slug: "coach-coachee".to_string(),
422+
created_at: chrono::Utc::now().into(),
423+
updated_at: chrono::Utc::now().into(),
424+
}];
425+
426+
let coach_organization = entity::organizations::Model {
427+
id: coach_organization_id,
428+
name: "Organization".to_string(),
429+
slug: "organization".to_string(),
430+
logo: None,
431+
created_at: chrono::Utc::now().into(),
432+
updated_at: chrono::Utc::now().into(),
433+
};
434+
435+
let coachee_organization = entity::organizations::Model {
436+
id: coachee_organization_id,
437+
name: "Organization".to_string(),
438+
slug: "organization".to_string(),
439+
logo: None,
440+
created_at: chrono::Utc::now().into(),
441+
updated_at: chrono::Utc::now().into(),
442+
};
345443

346444
let db = MockDatabase::new(DatabaseBackend::Postgres)
347-
.append_query_results(vec![vec![Model {
348-
id: Id::new_v4(),
349-
organization_id: organization_id.clone(),
350-
coach_id: coach_id.clone(),
351-
coachee_id: coachee_id.clone(),
352-
slug: "coach-coachee".to_string(),
353-
created_at: chrono::Utc::now().into(),
354-
updated_at: chrono::Utc::now().into(),
355-
}]])
356-
.append_exec_results(vec![MockExecResult {
357-
last_insert_id: 0,
358-
rows_affected: 1,
359-
}])
445+
.append_query_results(vec![vec![coach_user]])
446+
.append_query_results(vec![vec![coachee_user]])
447+
.append_query_results(vec![vec![coach_organization]])
448+
.append_query_results(vec![vec![coachee_organization]])
449+
.append_query_results(vec![coaching_relationships])
360450
.into_connection();
361451

362452
let model = Model {
@@ -369,7 +459,8 @@ mod tests {
369459
updated_at: chrono::Utc::now().into(),
370460
};
371461

372-
let result = create(&db, model).await;
462+
let result = create(&db, organization_id, model).await;
463+
println!("Result: {:?}", result);
373464
assert!(
374465
result
375466
== Err(Error {

entity_api/src/organization.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ use crate::{organization::Entity, uuid_parse_str};
33
use chrono::Utc;
44
use entity::{organizations::*, organizations_users, prelude::Organizations, Id};
55
use sea_orm::{
6-
entity::prelude::*, ActiveValue::Set, ActiveValue::Unchanged, DatabaseConnection, JoinType,
6+
entity::prelude::*, ActiveValue::Set, ActiveValue::Unchanged, ConnectionTrait, JoinType,
77
QuerySelect, TryIntoModel,
88
};
99
use slugify::slugify;
1010
use std::collections::HashMap;
1111

1212
use log::*;
1313

14-
pub async fn create(db: &DatabaseConnection, organization_model: Model) -> Result<Model, Error> {
14+
pub async fn create(db: &impl ConnectionTrait, organization_model: Model) -> Result<Model, Error> {
1515
debug!(
1616
"New Organization Model to be inserted: {:?}",
1717
organization_model
@@ -32,7 +32,7 @@ pub async fn create(db: &DatabaseConnection, organization_model: Model) -> Resul
3232
Ok(organization_active_model.insert(db).await?)
3333
}
3434

35-
pub async fn update(db: &DatabaseConnection, id: Id, model: Model) -> Result<Model, Error> {
35+
pub async fn update(db: &impl ConnectionTrait, id: Id, model: Model) -> Result<Model, Error> {
3636
let organization = find_by_id(db, id).await?;
3737

3838
let active_model: ActiveModel = ActiveModel {
@@ -46,13 +46,13 @@ pub async fn update(db: &DatabaseConnection, id: Id, model: Model) -> Result<Mod
4646
Ok(active_model.update(db).await?.try_into_model()?)
4747
}
4848

49-
pub async fn delete_by_id(db: &DatabaseConnection, id: Id) -> Result<(), Error> {
49+
pub async fn delete_by_id(db: &impl ConnectionTrait, id: Id) -> Result<(), Error> {
5050
let organization_model = find_by_id(db, id).await?;
5151
organization_model.delete(db).await?;
5252
Ok(())
5353
}
5454

55-
pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<Model>, Error> {
55+
pub async fn find_all(db: &impl ConnectionTrait) -> Result<Vec<Model>, Error> {
5656
Ok(Entity::find().all(db).await?)
5757
}
5858

@@ -64,7 +64,7 @@ pub async fn find_by_id(db: &impl ConnectionTrait, id: Id) -> Result<Model, Erro
6464
}
6565

6666
pub async fn find_by(
67-
db: &DatabaseConnection,
67+
db: &impl ConnectionTrait,
6868
params: HashMap<String, String>,
6969
) -> Result<Vec<Model>, Error> {
7070
let mut query = Entity::find();
@@ -87,7 +87,7 @@ pub async fn find_by(
8787
Ok(query.distinct().all(db).await?)
8888
}
8989

90-
pub async fn find_by_user(db: &DatabaseConnection, user_id: Id) -> Result<Vec<Model>, Error> {
90+
pub async fn find_by_user(db: &impl ConnectionTrait, user_id: Id) -> Result<Vec<Model>, Error> {
9191
let organizations = by_user(Entity::find(), user_id).await.all(db).await?;
9292

9393
Ok(organizations)

migration/src/refactor_platform_rs.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
-- SQL dump generated using DBML (dbml.dbdiagram.io)
22
-- Database: PostgreSQL
3-
-- Generated at: 2025-06-11T12:15:26.648Z
3+
-- Generated at: 2025-06-14T12:21:13.185Z
44

55

66
CREATE TYPE "refactor_platform"."status" AS ENUM (

web/src/controller/organization/coaching_relationship_controller.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,21 @@ use log::*;
3333
pub async fn create(
3434
CompareApiVersion(_v): CompareApiVersion,
3535
State(app_state): State<AppState>,
36+
Path(organization_id): Path<Id>,
37+
AuthenticatedUser(_user): AuthenticatedUser,
3638
Json(coaching_relationship_model): Json<coaching_relationships::Model>,
3739
) -> Result<impl IntoResponse, Error> {
3840
debug!(
3941
"CREATE new Coaching Relationship from: {:?}",
4042
coaching_relationship_model
4143
);
4244

43-
let coaching_relationship: coaching_relationships::Model =
44-
CoachingRelationshipApi::create(app_state.db_conn_ref(), coaching_relationship_model)
45-
.await?;
45+
let coaching_relationship: CoachingRelationshipWithUserNames = CoachingRelationshipApi::create(
46+
app_state.db_conn_ref(),
47+
organization_id,
48+
coaching_relationship_model,
49+
)
50+
.await?;
4651

4752
debug!(
4853
"Newly created Coaching Relationship: {:?}",

web/src/controller/organization/user_controller.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,13 @@ pub async fn index(
6363
pub(crate) async fn create(
6464
CompareApiVersion(_v): CompareApiVersion,
6565
State(app_state): State<AppState>,
66-
AuthenticatedUser(authenticated_user): AuthenticatedUser,
66+
AuthenticatedUser(_authenticated_user): AuthenticatedUser,
6767
Path(organization_id): Path<Id>,
6868
Json(user_model): Json<users::Model>,
6969
) -> Result<impl IntoResponse, Error> {
70-
let user = UserApi::create_user_and_coaching_relationship(
71-
app_state.db_conn_ref(),
72-
organization_id,
73-
authenticated_user.id,
74-
user_model,
75-
)
76-
.await?;
70+
let user =
71+
UserApi::create_by_organization(app_state.db_conn_ref(), organization_id, user_model)
72+
.await?;
7773
info!("User created: {:?}", user);
7874
Ok(Json(ApiResponse::new(StatusCode::CREATED.into(), user)))
7975
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use crate::protect::{Predicate, UserInOrganization, UserIsAdmin};
2+
use crate::{extractors::authenticated_user::AuthenticatedUser, AppState};
3+
use axum::{
4+
extract::{Path, Request, State},
5+
middleware::Next,
6+
response::IntoResponse,
7+
};
8+
9+
use domain::Id;
10+
11+
/// Checks that the authenticated user is associated with the organization specified by `organization_id`
12+
/// and that the authenticated user is an admin
13+
/// Intended to be given to axum::middleware::from_fn_with_state in the router
14+
pub(crate) async fn create(
15+
State(app_state): State<AppState>,
16+
AuthenticatedUser(authenticated_user): AuthenticatedUser,
17+
Path(organization_id): Path<Id>,
18+
request: Request,
19+
next: Next,
20+
) -> impl IntoResponse {
21+
let checks: Vec<Predicate> = vec![
22+
Predicate::new(UserInOrganization, vec![organization_id]),
23+
Predicate::new(UserIsAdmin, vec![]),
24+
];
25+
26+
crate::protect::authorize(&app_state, authenticated_user, request, next, checks).await
27+
}

web/src/protect/organizations/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
pub(crate) mod coaching_relationships;
12
pub(crate) mod users;

web/src/router.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,10 @@ fn organization_coaching_relationship_routes(app_state: AppState) -> Router {
242242
"/organizations/:organization_id/coaching_relationships",
243243
post(coaching_relationship_controller::create),
244244
)
245+
.route_layer(from_fn_with_state(
246+
app_state.clone(),
247+
protect::organizations::coaching_relationships::create,
248+
))
245249
.merge(
246250
// GET /organizations/:organization_id/coaching_relationships
247251
Router::new()

0 commit comments

Comments
 (0)