Skip to content

Conversation

@calebbourg
Copy link
Collaborator

@calebbourg calebbourg commented Dec 9, 2025

Description

Implement role-based access control for coaching relationships to ensure users can only view relationships they are authorized to see based on
their role permissions.

Changes

  • Add find_by_user_and_organization_with_user_names function in entity_api layer to filter coaching relationships by user
  • Implement find_by_organization_for_user_with_user_names in domain layer with role-based filtering logic:
    • SuperAdmins: see all relationships in any organization
    • Org Admins: see all relationships in their organization
    • Regular users: see only relationships where they are coach or coachee
  • Update coaching relationships index endpoint to use the new role-aware function
  • Add Clone derive to CoachingRelationshipWithUserNames for test extensibility
  • Add mock feature to domain Cargo.toml for future testing support

Testing Strategy

Manual tested by:

  1. Creating users with different roles (SuperAdmin, Admin, regular user)
  2. Creating coaching relationships between users in the same and different organizations
  3. Querying the coaching relationships endpoint with each user and verifying they only see authorized relationships

calebbourg and others added 3 commits December 9, 2025 04:41
Add find_by_user_and_organization_with_user_names function to filter
coaching relationships by user within an organization. Returns only
relationships where the user is the coach OR coachee.

Also add Clone derive to CoachingRelationshipWithUserNames to enable
future test extensibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Implement find_by_organization_for_user_with_user_names in domain layer
to enforce role-based filtering:
- SuperAdmins: see all relationships in any organization
- Org Admins: see all relationships in their organization
- Regular users: see only relationships where they are coach/coachee

Business logic is encapsulated in the domain layer following the pattern
established in organization.rs. Role checks query the user_roles table
to determine access level.

Add comprehensive test documentation describing expected behavior for
each role type, with guidance for future integration tests.

Add mock feature to domain Cargo.toml to support future testing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Update the coaching relationships index endpoint to use the new
find_by_organization_for_user_with_user_names function, which respects
user roles:

Previously: All users could see all relationships in their organization
Now: Users see only relationships based on their role permissions

This fixes a security issue where regular users could view coaching
relationships they weren't involved in. The controller now passes the
authenticated user ID to the domain layer, which handles role checking
and appropriate data filtering.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@calebbourg calebbourg self-assigned this Dec 9, 2025
@calebbourg calebbourg requested a review from jhodapp December 9, 2025 09:43
@calebbourg calebbourg added the bug fix Contains a fix to a known bug label Dec 9, 2025
@calebbourg calebbourg added this to the 1.0.0-beta2 milestone Dec 9, 2025
@calebbourg calebbourg marked this pull request as ready for review December 9, 2025 09:44
Copy link
Member

@jhodapp jhodapp left a comment

Choose a reason for hiding this comment

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

Got a few recommendations for improving the security and performance of this, otherwise looking great. Thanks!

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

Comment on lines 36 to 71
let is_super_admin = user_roles::Entity::find()
.filter(user_roles::Column::UserId.eq(user_id))
.filter(user_roles::Column::Role.eq(Role::SuperAdmin))
.filter(user_roles::Column::OrganizationId.is_null())
.one(db)
.await
.map_err(|e| Error {
source: Some(Box::new(e)),
error_kind: DomainErrorKind::Internal(InternalErrorKind::Entity(
EntityErrorKind::DbTransaction,
)),
})?
.is_some();

// Check if user is an admin for this specific organization
let is_org_admin = user_roles::Entity::find()
.filter(user_roles::Column::UserId.eq(user_id))
.filter(user_roles::Column::Role.eq(Role::Admin))
.filter(user_roles::Column::OrganizationId.eq(organization_id))
.one(db)
.await
.map_err(|e| Error {
source: Some(Box::new(e)),
error_kind: DomainErrorKind::Internal(InternalErrorKind::Entity(
EntityErrorKind::DbTransaction,
)),
})?
.is_some();

let coaching_relationships = if is_super_admin || is_org_admin {
// Admin users see all relationships in the organization
find_by_organization_with_user_names(db, 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(db, user_id, organization_id).await?
};
Copy link
Member

Choose a reason for hiding this comment

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

Time-of-Check to Time-of-Use vulnerability:

// TIME OF CHECK: Query role
let is_super_admin = user_roles::Entity::find()...one(db).await?;
let is_org_admin = user_roles::Entity::find()...one(db).await?;

// GAP: Role could be revoked here by another transaction

// TIME OF USE: Query data based on stale role check
let coaching_relationships = if is_super_admin || is_org_admin {
    find_by_organization_with_user_names(db, organization_id).await?
} else {
    find_by_user_and_organization_with_user_names(db, user_id, organization_id).await?
};

Attack Scenario:

  1. Admin requests coaching relationships
  2. Role check passes (line 36-63)
  3. Another admin revokes their role
  4. Data query executes with now-invalid permissions (line 65-71)

Mitigation:

  • Database Transaction: Wrap the entire operation in a serializable transaction

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added! bbe8913

…ships

Optimize find_by_organization_for_user_with_user_names by consolidating
two separate role check queries into a single atomic query using
Condition::any() with nested conditions.

Benefits:
- Reduces database round-trips from 2 to 1 (50% improvement)
- Eliminates race condition window between queries
- Improves performance by reducing network overhead
- Maintains same functional behavior and access control logic

The combined query checks if user is either:
1. SuperAdmin (role = SuperAdmin AND organization_id IS NULL), OR
2. Organization Admin (role = Admin AND organization_id = specific org)

This refactoring also removes test documentation that was not executable
and better belongs in integration tests.
Improve architectural separation by moving SeaORM-specific role checking
logic out of the domain layer and into the entity_api layer.

Changes:
1. Add entity_api::user::has_admin_access() function
   - Encapsulates role checking query logic
   - Accepts ConnectionTrait for flexibility
   - Returns simple bool result

2. Simplify domain::coaching_relationship module
   - Remove direct SeaORM imports (Condition, QueryFilter, etc.)
   - Call entity_api::user::has_admin_access() instead of building queries
   - Focus on business logic rather than data access implementation

Benefits:
- Better separation of concerns: domain focuses on business logic
- Improved reusability: has_admin_access() can be used elsewhere
- Easier testing: domain layer no longer coupled to SeaORM specifics
- Cleaner imports: domain layer only imports DatabaseConnection
- Maintains single-query optimization for performance

This follows the principle of keeping ORM/database implementation
details isolated in the entity_api layer.
Add 6 unit tests for the new entity_api::user::has_admin_access() function
using SeaORM's MockDatabase to verify role-based access control logic.

Test Coverage:
1. has_admin_access_returns_true_for_super_admin
   - Verifies SuperAdmin with NULL organization_id has access to any org

2. has_admin_access_returns_true_for_organization_admin
   - Verifies Admin role for specific organization grants access

3. has_admin_access_returns_false_for_regular_user
   - Verifies users without admin roles are correctly denied

4. has_admin_access_returns_false_for_admin_of_different_organization
   - Verifies admin access is scoped to specific organizations

5. has_admin_access_returns_false_for_nonexistent_user
   - Verifies nonexistent users are handled gracefully

6. has_admin_access_with_admin_role_for_multiple_organizations
   - Verifies users can be admin of one org but not another

All tests use MockDatabase to avoid database dependencies and run quickly.
Tests verify both positive cases (access granted) and negative cases
(access denied) for comprehensive coverage.

Test Results: 6 passed; 0 failed
Wrap role-based data access in a database transaction to eliminate
Time-of-Check to Time-of-Use (TOCTOU) race conditions.

Changes:
1. Update entity_api coaching relationship functions to accept ConnectionTrait
   - find_by_organization_with_user_names: &DatabaseConnection -> &impl ConnectionTrait
   - find_by_user_and_organization_with_user_names: &DatabaseConnection -> &impl ConnectionTrait
   - Now compatible with both database connections and transactions

2. Wrap domain::coaching_relationship function in transaction
   - Begin transaction before role check
   - Execute all queries within transaction scope
   - Commit transaction after data retrieval
   - Ensures atomic snapshot of database state

Security Benefits:
- Prevents race condition where user roles change between check and data access
- Provides REPEATABLE READ isolation level guarantees (PostgreSQL default)
- Ensures consistent view of user permissions and accessible data
- Eliminates window for privilege escalation during query execution

The transaction ensures that the role check and coaching relationship
retrieval see the same consistent snapshot of the database, preventing
scenarios where a user's admin privileges are revoked mid-query but they
still receive admin-level data.

All existing tests pass with no behavioral changes.
Copy link
Member

@jhodapp jhodapp left a comment

Choose a reason for hiding this comment

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

Looks great, thanks for the fix!

@jhodapp jhodapp merged commit 81c3446 into main Dec 11, 2025
5 checks passed
@jhodapp jhodapp deleted the fix-relationship-data-issue branch December 11, 2025 15:04
@github-project-automation github-project-automation bot moved this from Review to ✅ Done in Refactor Coaching Platform Dec 11, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug fix Contains a fix to a known bug

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

3 participants