-
Notifications
You must be signed in to change notification settings - Fork 2
Fix relationship data issue #207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
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>
jhodapp
left a comment
There was a problem hiding this 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> { |
There was a problem hiding this comment.
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:
- User A is authenticated but has no relationship with Organization X
- User A calls GET /organizations/{org_x_id}/coaching_relationships
- The function falls through to find_by_user_and_organization_with_user_names() (line 70)
- 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),
});
}There was a problem hiding this comment.
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
domain/src/coaching_relationship.rs
Outdated
| 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? | ||
| }; |
There was a problem hiding this comment.
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:
- Admin requests coaching relationships
- Role check passes (line 36-63)
- Another admin revokes their role
- Data query executes with now-invalid permissions (line 65-71)
Mitigation:
- Database Transaction: Wrap the entire operation in a serializable transaction
There was a problem hiding this comment.
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.
jhodapp
left a comment
There was a problem hiding this 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!
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
find_by_user_and_organization_with_user_namesfunction in entity_api layer to filter coaching relationships by userfind_by_organization_for_user_with_user_namesin domain layer with role-based filtering logic:Clonederive toCoachingRelationshipWithUserNamesfor test extensibilityTesting Strategy
Manual tested by: