Skip to content
Merged
3 changes: 3 additions & 0 deletions domain/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@ features = ["debug-print", "runtime-tokio-native-tls", "sqlx-postgres"]
mockito = "1.6"
serial_test = "3.1"

[features]
mock = ["entity_api/mock"]



58 changes: 55 additions & 3 deletions domain/src/coaching_relationship.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use crate::coaching_relationships::Model;
use crate::error::Error;
use crate::error::{DomainErrorKind, EntityErrorKind, Error, InternalErrorKind};
use entity_api::query::{IntoQueryFilterMap, QuerySort};
use entity_api::{coaching_relationships, query};
use sea_orm::DatabaseConnection;
use sea_orm::{DatabaseConnection, TransactionTrait};

pub use entity_api::coaching_relationship::{
create, find_by_id, find_by_organization_with_user_names, find_by_user,
get_relationship_with_user_names, CoachingRelationshipWithUserNames,
find_by_user_and_organization_with_user_names, get_relationship_with_user_names,
CoachingRelationshipWithUserNames,
};

pub async fn find_by<P>(db: &DatabaseConnection, params: P) -> Result<Vec<Model>, Error>
Expand All @@ -20,3 +21,54 @@ where
.await?;
Ok(coaching_relationships)
}

/// Finds coaching relationships for a user within an organization, respecting role-based access.
///
/// - SuperAdmins (global role with organization_id = NULL) see all relationships in the organization
/// - Admins (organization-specific role) see all relationships in their organization
/// - Regular users see only relationships where they are the coach or coachee
///
/// This function uses a database transaction to prevent Time-of-Check to Time-of-Use (TOCTOU)
/// vulnerabilities by ensuring the role check and data retrieval happen atomically within
/// a consistent database snapshot.
pub async fn find_by_organization_for_user_with_user_names(
db: &DatabaseConnection,
user_id: crate::Id,
organization_id: crate::Id,
) -> Result<Vec<CoachingRelationshipWithUserNames>, Error> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a correctness and security perspective, I think it makes sense to check to make sure that this user is first a member of the provided organization before doing any of the other checks.

Here's Claude's recommendation:

Attack Scenario:

  1. User A is authenticated but has no relationship with Organization X
  2. User A calls GET /organizations/{org_x_id}/coaching_relationships
  3. The function falls through to find_by_user_and_organization_with_user_names() (line 70)
  4. This returns an empty list (not an error), which leaks that Organization X exists

Current Flow (domain/src/coaching_relationship.rs:30-73):

pub async fn find_by_organization_for_user_with_user_names(
    db: &DatabaseConnection,
    user_id: crate::Id,
    organization_id: crate::Id,
) -> Result<Vec<CoachingRelationshipWithUserNames>, Error> {
    // Check admin roles... ✓
    let is_super_admin = ...;
    let is_org_admin = ...;

    // ⚠️ MISSING: Verify user belongs to this organization!

    if is_super_admin || is_org_admin {
        find_by_organization_with_user_names(db, organization_id).await?
    } else {
        // Returns empty vec if user has no relationships - information leak
        find_by_user_and_organization_with_user_names(db, user_id, organization_id).await?
    }
}

Recommendation: Add organization membership verification:

// Add before the admin checks
let is_organization_member = check_user_organization_membership(db, user_id, organization_id).await?;
if !is_organization_member && !is_super_admin {
    return Err(Error {
        source: None,
        error_kind: DomainErrorKind::Authorization(AuthorizationErrorKind::Forbidden),
    });
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually was already handled in our authorization checks but I updated them to use the newer pattern and directory structure 3e0055a

// Begin transaction to ensure atomicity and prevent TOCTOU vulnerabilities
let txn = db.begin().await.map_err(|e| Error {
source: Some(Box::new(e)),
error_kind: DomainErrorKind::Internal(InternalErrorKind::Entity(
EntityErrorKind::DbTransaction,
)),
})?;

// Check if user has admin access using entity_api layer
let is_admin = entity_api::user::has_admin_access(&txn, user_id, organization_id)
.await
.map_err(|e| Error {
source: Some(Box::new(e)),
error_kind: DomainErrorKind::Internal(InternalErrorKind::Entity(
EntityErrorKind::DbTransaction,
)),
})?;

let coaching_relationships = if is_admin {
// Admin users see all relationships in the organization
find_by_organization_with_user_names(&txn, organization_id).await?
} else {
// Regular users see only relationships they're associated with (as coach or coachee)
find_by_user_and_organization_with_user_names(&txn, user_id, organization_id).await?
};

// Commit transaction
txn.commit().await.map_err(|e| Error {
source: Some(Box::new(e)),
error_kind: DomainErrorKind::Internal(InternalErrorKind::Entity(
EntityErrorKind::DbTransaction,
)),
})?;

Ok(coaching_relationships)
}
45 changes: 43 additions & 2 deletions entity_api/src/coaching_relationship.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ pub async fn find_by_organization(
}

pub async fn find_by_organization_with_user_names(
db: &DatabaseConnection,
db: &impl ConnectionTrait,
organization_id: Id,
) -> Result<Vec<CoachingRelationshipWithUserNames>, Error> {
let coaches = Alias::new("coaches");
Expand Down Expand Up @@ -159,6 +159,47 @@ pub async fn find_by_organization_with_user_names(
Ok(query.all(db).await?)
}

pub async fn find_by_user_and_organization_with_user_names(
db: &impl ConnectionTrait,
user_id: Id,
organization_id: Id,
) -> Result<Vec<CoachingRelationshipWithUserNames>, Error> {
let coaches = Alias::new("coaches");
let coachees = Alias::new("coachees");

let query = by_organization(coaching_relationships::Entity::find(), organization_id)
.await
.filter(
Condition::any()
.add(coaching_relationships::Column::CoachId.eq(user_id))
.add(coaching_relationships::Column::CoacheeId.eq(user_id)),
)
.join_as(
JoinType::Join,
coaches::Relation::CoachingRelationships.def().rev(),
coaches.clone(),
)
.join_as(
JoinType::Join,
coachees::Relation::CoachingRelationships.def().rev(),
coachees.clone(),
)
.select_only()
.column(coaching_relationships::Column::Id)
.column(coaching_relationships::Column::OrganizationId)
.column(coaching_relationships::Column::CoachId)
.column(coaching_relationships::Column::CoacheeId)
.column(coaching_relationships::Column::CreatedAt)
.column(coaching_relationships::Column::UpdatedAt)
.column_as(Expr::cust("coaches.first_name"), "coach_first_name")
.column_as(Expr::cust("coaches.last_name"), "coach_last_name")
.column_as(Expr::cust("coachees.first_name"), "coachee_first_name")
.column_as(Expr::cust("coachees.last_name"), "coachee_last_name")
.into_model::<CoachingRelationshipWithUserNames>();

Ok(query.all(db).await?)
}

pub async fn get_relationship_with_user_names(
db: &DatabaseConnection,
relationship_id: Id,
Expand Down Expand Up @@ -237,7 +278,7 @@ pub async fn delete_by_user_id(db: &impl ConnectionTrait, user_id: Id) -> Result

// A convenient combined struct that holds the results of looking up the Users associated
// with the coach/coachee ids. This should be used as an implementation detail only.
#[derive(FromQueryResult, Debug, PartialEq)]
#[derive(FromQueryResult, Debug, PartialEq, Clone)]
pub struct CoachingRelationshipWithUserNames {
pub id: Id,
pub coach_id: Id,
Expand Down
196 changes: 195 additions & 1 deletion entity_api/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use entity::users::{ActiveModel, Column, Entity, Model};
use entity::{roles, user_roles, Id};
use log::*;
use password_auth;
use sea_orm::{entity::prelude::*, ConnectionTrait, DatabaseConnection, Set, TransactionTrait};
use sea_orm::{
entity::prelude::*, Condition, ConnectionTrait, DatabaseConnection, Set, TransactionTrait,
};
use serde::Deserialize;
use std::sync::Arc;
use utoipa::{IntoParams, ToSchema};
Expand Down Expand Up @@ -127,6 +129,42 @@ pub async fn find_by_organization(
.collect())
}

/// Checks if a user has admin privileges for an organization.
///
/// Returns `true` if the user is:
/// - A SuperAdmin (has `SuperAdmin` role with `organization_id = NULL`), OR
/// - An Admin for the specific organization (has `Admin` role for the given organization)
///
/// This function encapsulates the SeaORM query logic for role checking,
/// keeping database-specific implementation details out of the domain layer.
pub async fn has_admin_access(
db: &impl ConnectionTrait,
user_id: Id,
organization_id: Id,
) -> Result<bool, Error> {
let admin_role = user_roles::Entity::find()
.filter(user_roles::Column::UserId.eq(user_id))
.filter(
Condition::any()
// SuperAdmin with organization_id = NULL
.add(
Condition::all()
.add(user_roles::Column::Role.eq(Role::SuperAdmin))
.add(user_roles::Column::OrganizationId.is_null()),
)
// Admin for this specific organization
.add(
Condition::all()
.add(user_roles::Column::Role.eq(Role::Admin))
.add(user_roles::Column::OrganizationId.eq(organization_id)),
),
)
.one(db)
.await?;

Ok(admin_role.is_some())
}

pub async fn delete(db: &impl ConnectionTrait, user_id: Id) -> Result<(), Error> {
Entity::delete_by_id(user_id).exec(db).await?;
Ok(())
Expand Down Expand Up @@ -382,4 +420,160 @@ mod test {

Ok(())
}

#[tokio::test]
async fn has_admin_access_returns_true_for_super_admin() -> Result<(), Error> {
let user_id = Id::new_v4();
let organization_id = Id::new_v4();
let role_id = Id::new_v4();
let now = chrono::Utc::now();

// Create a SuperAdmin role with NULL organization_id
let super_admin_role = user_roles::Model {
id: role_id,
user_id,
role: Role::SuperAdmin,
organization_id: None,
created_at: now.into(),
updated_at: now.into(),
};

let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![vec![super_admin_role]])
.into_connection();

let result = has_admin_access(&db, user_id, organization_id).await?;

assert!(
result,
"SuperAdmin should have admin access to any organization"
);

Ok(())
}

#[tokio::test]
async fn has_admin_access_returns_true_for_organization_admin() -> Result<(), Error> {
let user_id = Id::new_v4();
let organization_id = Id::new_v4();
let role_id = Id::new_v4();
let now = chrono::Utc::now();

// Create an Admin role for the specific organization
let org_admin_role = user_roles::Model {
id: role_id,
user_id,
role: Role::Admin,
organization_id: Some(organization_id),
created_at: now.into(),
updated_at: now.into(),
};

let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![vec![org_admin_role]])
.into_connection();

let result = has_admin_access(&db, user_id, organization_id).await?;

assert!(
result,
"Organization Admin should have admin access to their organization"
);

Ok(())
}

#[tokio::test]
async fn has_admin_access_returns_false_for_regular_user() -> Result<(), Error> {
let user_id = Id::new_v4();
let organization_id = Id::new_v4();

// Mock returns empty result (no admin roles found)
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![Vec::<user_roles::Model>::new()])
.into_connection();

let result = has_admin_access(&db, user_id, organization_id).await?;

assert!(!result, "Regular users should not have admin access");

Ok(())
}

#[tokio::test]
async fn has_admin_access_returns_false_for_admin_of_different_organization(
) -> Result<(), Error> {
let user_id = Id::new_v4();
let organization_id_a = Id::new_v4(); // Organization being queried
let _organization_id_b = Id::new_v4(); // Organization where user is admin

// Mock returns empty result (no matching admin role for org A)
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![Vec::<user_roles::Model>::new()])
.into_connection();

let result = has_admin_access(&db, user_id, organization_id_a).await?;

assert!(
!result,
"Admin of different organization should not have access"
);

Ok(())
}

#[tokio::test]
async fn has_admin_access_returns_false_for_nonexistent_user() -> Result<(), Error> {
let nonexistent_user_id = Id::new_v4();
let organization_id = Id::new_v4();

// Mock returns empty result (user doesn't exist)
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![Vec::<user_roles::Model>::new()])
.into_connection();

let result = has_admin_access(&db, nonexistent_user_id, organization_id).await?;

assert!(!result, "Nonexistent user should not have admin access");

Ok(())
}

#[tokio::test]
async fn has_admin_access_with_admin_role_for_multiple_organizations() -> Result<(), Error> {
let user_id = Id::new_v4();
let organization_id_a = Id::new_v4();
let organization_id_b = Id::new_v4();
let role_id = Id::new_v4();
let now = chrono::Utc::now();

// Create an Admin role for organization A
let org_admin_role = user_roles::Model {
id: role_id,
user_id,
role: Role::Admin,
organization_id: Some(organization_id_a),
created_at: now.into(),
updated_at: now.into(),
};

let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![vec![org_admin_role]])
.into_connection();

// Should have access to organization A
let result_a = has_admin_access(&db, user_id, organization_id_a).await?;
assert!(result_a, "Should have admin access to organization A");

// Create new mock for organization B query (no matching role)
let db_b = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![Vec::<user_roles::Model>::new()])
.into_connection();

// Should NOT have access to organization B
let result_b = has_admin_access(&db_b, user_id, organization_id_b).await?;
assert!(!result_b, "Should not have admin access to organization B");

Ok(())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,22 @@ pub async fn read(
)]
pub async fn index(
CompareApiVersion(_v): CompareApiVersion,
AuthenticatedUser(_user): AuthenticatedUser,
AuthenticatedUser(user): AuthenticatedUser,
State(app_state): State<AppState>,
Path(organization_id): Path<Id>,
) -> Result<impl IntoResponse, Error> {
debug!("GET all CoachingRelationships");
let coaching_relationships = CoachingRelationshipApi::find_by_organization_with_user_names(
app_state.db_conn_ref(),
organization_id,
)
.await?;
debug!(
"GET all CoachingRelationships for user {} in organization {}",
user.id, organization_id
);

let coaching_relationships =
CoachingRelationshipApi::find_by_organization_for_user_with_user_names(
app_state.db_conn_ref(),
user.id,
organization_id,
)
.await?;

debug!("Found CoachingRelationships: {coaching_relationships:?}");

Expand Down
Loading