diff --git a/apps/backend/src/rhesis/backend/app/auth/org_membership_hook.py b/apps/backend/src/rhesis/backend/app/auth/org_membership_hook.py index 0ed4233a08..50121f636f 100644 --- a/apps/backend/src/rhesis/backend/app/auth/org_membership_hook.py +++ b/apps/backend/src/rhesis/backend/app/auth/org_membership_hook.py @@ -11,7 +11,7 @@ ``organization_member`` is an EE concept, and core must never import EE (``community-boundary`` CI job). So core exposes this tiny hook registry and EE registers a handler in its :func:`~rhesis.backend.ee.bootstrap`. Core calls -:func:`on_user_org_assigned` from ``crud.create_user`` (and the onboarding / +:func:`on_user_org_assigned` from ``crud.user.create_user`` (and the onboarding / re-invite paths); when no handler is registered (community build) it is a no-op. Contract diff --git a/apps/backend/src/rhesis/backend/app/auth/providers/email.py b/apps/backend/src/rhesis/backend/app/auth/providers/email.py index 6b6d8288ff..f4fbde44c3 100644 --- a/apps/backend/src/rhesis/backend/app/auth/providers/email.py +++ b/apps/backend/src/rhesis/backend/app/auth/providers/email.py @@ -110,10 +110,10 @@ async def authenticate( ) # Import here to avoid circular imports - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud # Look up user by email - user = crud.get_user_by_email(db, email) + user = user_crud.get_user_by_email(db, email) if not user: logger.warning( @@ -210,7 +210,7 @@ async def register( ) # Import here to avoid circular imports - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.schemas import UserCreate from rhesis.backend.app.utils.validation import validate_and_normalize_email @@ -227,7 +227,7 @@ async def register( ) # Check if user already exists - existing_user = crud.get_user_by_email(db, normalized_email) + existing_user = user_crud.get_user_by_email(db, normalized_email) if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -246,7 +246,7 @@ async def register( is_active=True, ) - user = crud.create_user(db, user_data) + user = user_crud.create_user(db, user_data) logger.info( "New user registered via email: %s", redact_email(normalized_email), diff --git a/apps/backend/src/rhesis/backend/app/auth/user_utils.py b/apps/backend/src/rhesis/backend/app/auth/user_utils.py index 1dab3cc768..fcad3fb8c0 100644 --- a/apps/backend/src/rhesis/backend/app/auth/user_utils.py +++ b/apps/backend/src/rhesis/backend/app/auth/user_utils.py @@ -6,7 +6,6 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session -from rhesis.backend.app import crud from rhesis.backend.app.auth.constants import UNAUTHORIZED_MESSAGE, AuthenticationMethod from rhesis.backend.app.auth.principal import ( REQUEST_STATE_API_TOKEN_PROJECT_ID, @@ -16,6 +15,7 @@ ) from rhesis.backend.app.auth.token_utils import get_secret_key, verify_jwt_token from rhesis.backend.app.auth.token_validation import validate_token +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.crud.token import get_token_by_value from rhesis.backend.app.database import get_db from rhesis.backend.app.models.user import User @@ -68,7 +68,7 @@ def find_or_create_user_from_auth(db: Session, auth_user: "AuthUser") -> User: normalized_email = validate_and_normalize_email(auth_user.email) # First try to find user by email (this is our primary matching criteria) - user = crud.get_user_by_email(db, normalized_email) + user = user_crud.get_user_by_email(db, normalized_email) if user: # Found user by email - update profile info and provider details @@ -104,7 +104,7 @@ def find_or_create_user_from_auth(db: Session, auth_user: "AuthUser") -> User: is_email_verified=True, # OAuth/credentials auth confirms email ownership last_login_at=current_time, ) - user = crud.create_user(db, user_data) + user = user_crud.create_user(db, user_data) is_new_user = True mark_user_joined_if_needed(user, when=current_time) @@ -164,7 +164,7 @@ async def get_current_user(request: Request) -> Optional[User]: # Get the user with a basic session - no organization context needed for user lookup with get_db() as db: - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) # User must have an organization_id to proceed if not user or not user.organization_id: @@ -188,7 +188,7 @@ async def get_user_from_jwt(token: str, secret_key: str) -> Optional[User]: if user_id: # Get the user with a basic session - no organization context needed for user lookup with get_db() as db: - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if not user: return None @@ -240,7 +240,7 @@ async def get_authenticated_user_with_context( if is_valid: token = get_token_by_value(db, token_value) if token: - user = crud.get_user_by_id(db, token.user_id) + user = user_crud.get_user_by_id(db, token.user_id) # Handle user based on organization requirement # Must be inside the context manager diff --git a/apps/backend/src/rhesis/backend/app/crud/__init__.py b/apps/backend/src/rhesis/backend/app/crud/__init__.py index 83ee6289cf..ea48b86e0d 100644 --- a/apps/backend/src/rhesis/backend/app/crud/__init__.py +++ b/apps/backend/src/rhesis/backend/app/crud/__init__.py @@ -780,160 +780,6 @@ def delete_topic( return delete_item(db, models.Topic, topic_id, organization_id, user_id) -# User CRUD -def get_user( - db: Session, user_id: uuid.UUID, organization_id: str = None, tenant_user_id: str = None -) -> Optional[models.User]: - """Get user.""" - return get_item(db, models.User, user_id, organization_id, tenant_user_id) - - -def get_users( - db: Session, - skip: int = 0, - limit: int = 10, - sort_by: str = "created_at", - sort_order: str = "desc", - filter: str | None = None, - organization_id: str = None, - user_id: str = None, -) -> List[models.User]: - return get_items( - db, - models.User, - skip, - limit, - sort_by, - sort_order, - filter, - organization_id=organization_id, - user_id=user_id, - ) - - -def create_user(db: Session, user: schemas.UserCreate) -> models.User: - """Create a new user without RLS checks, because we're creating a new user that has no - organization_id""" - # Exclude fields not present on the User model - user_data = user.model_dump(exclude={"send_invite", "project_id"}) - db_user = models.User(**user_data) - db.add(db_user) - # Flush to get ID and other generated values before refresh - db.flush() - - # Seed the default org-role (EE) so the user is not locked out once RBAC is - # enabled for their org. No-op in community builds / when no org is set. - if db_user.organization_id is not None: - from rhesis.backend.app.auth.org_membership_hook import on_user_org_assigned - - on_user_org_assigned(db, db_user.id, db_user.organization_id) - - # Transaction commit is handled by the session context manager - db.refresh(db_user) - return db_user - - -def update_user(db: Session, user_id: uuid.UUID, user: schemas.UserUpdate) -> Optional[models.User]: - """Update user with special handling for onboarding (no organization)""" - # Direct query without RLS filters for user updates - db_user = db.query(models.User).filter(models.User.id == user_id).first() - if not db_user: - return None - - # Update user attributes - user_data = user.model_dump(exclude_unset=True) - for key, value in user_data.items(): - setattr(db_user, key, value) - - # Transaction commit/rollback is handled by the session context manager - return db_user - - -def delete_user( - db: Session, target_user_id: uuid.UUID, organization_id: str, user_id: str -) -> Optional[models.User]: - """ - Remove a user from their organization by setting organization_id to NULL. - - The user account remains active but loses organization access. - This preserves the user account and all their data while removing - organizational context. On next login, the user will go through - the onboarding flow again. - - Also removes all project memberships within the org and clears - default_project so no orphaned rows or stale settings remain. - - Args: - db: Database session - target_user_id: ID of user to remove from organization - organization_id: Organization ID for tenant context - user_id: ID of the current user performing the action (for tenant context) - - Returns: - Updated user object or None if not found - - Raises: - ValueError: If user tries to delete themselves - """ - from sqlalchemy.orm.attributes import flag_modified - - from rhesis.backend.app.models.project_membership import ProjectMembership - from rhesis.backend.app.scope import bypass_tenant_filter - - # Security check: Prevent users from deleting themselves - if str(target_user_id) == str(user_id): - raise ValueError("Users cannot remove themselves from the organization") - - # Get the user with tenant context - db_user = get_item(db, models.User, target_user_id, organization_id, user_id) - if db_user is None: - return None - - # Drop all project memberships within this org before nulling organization_id, - # while we can still identify them via the org FK. - with bypass_tenant_filter(): - memberships = ( - db.query(ProjectMembership) - .filter_by(user_id=target_user_id, organization_id=organization_id) - .all() - ) - for m in memberships: - db.delete(m) - - # Clear default_project — it's org-scoped so it would be stale after removal. - if db_user.settings.default_project is not None: - settings = db_user.settings.raw.copy() - settings.pop("default_project", None) - db_user.user_settings = settings - flag_modified(db_user, "user_settings") - - # Null the org FK last so the membership query above can still use it. - db_user.organization_id = None - - db.commit() - db.refresh(db_user) - - return db_user - - -def get_user_by_email(db: Session, email: str) -> Optional[models.User]: - from sqlalchemy import func - - return db.query(models.User).filter(func.lower(models.User.email) == email.lower()).first() - - -def get_user_by_id(db: Session, user_id: Union[str, UUID]) -> Optional[models.User]: - """Retrieve a user by their ID. Accepts both string and UUID.""" - try: - # Convert string to UUID if it's a string - if isinstance(user_id, str): - user_id = UUID(user_id) - return db.query(models.User).filter(models.User.id == user_id).first() - except ValueError: - # Handle invalid UUID string - return None - - # Organization CRUD def get_organization( db: Session, organization_id: uuid.UUID, tenant_organization_id: str = None, user_id: str = None diff --git a/apps/backend/src/rhesis/backend/app/crud/user.py b/apps/backend/src/rhesis/backend/app/crud/user.py new file mode 100644 index 0000000000..c95860d846 --- /dev/null +++ b/apps/backend/src/rhesis/backend/app/crud/user.py @@ -0,0 +1,198 @@ +"""CRUD operations for users. + +Part of the incremental split of the ``crud`` monolith: ``crud/__init__.py`` still holds +the bulk of the functions, and per-entity modules like this one take over as the code +around them is touched. + +Users are the one entity that regularly exists *outside* an organization, so most of these +functions deliberately sidestep the tenant machinery the rest of ``crud`` relies on. +``create_user`` builds the ``User`` row directly instead of going through ``create_item`` +because a user being invited or signing up has no ``organization_id`` yet and the +org-filtered helpers would reject it; ``update_user`` queries by primary key with no +organization filter for the same reason -- it is what the onboarding flow uses to attach a +brand-new user to the org it just created. ``get_user_by_email`` and ``get_user_by_id`` +are likewise unfiltered: they are the auth lookups that run *before* a tenant context +exists, which is why they take no ``organization_id``. Everything that runs with a tenant +context (``get_user``, ``get_users``, ``delete_user``) does apply the filter. + +``delete_user`` deletes nothing. It removes a user from their organization by nulling +``organization_id``; the account and all its data survive and the user lands back in the +onboarding flow on next login. Three things happen on the way, and their order is +load-bearing: + +- It refuses to act when the target is the caller, so an admin cannot lock themselves out + of the org they administer. +- It drops the user's ``ProjectMembership`` rows for the org first, inside + ``bypass_tenant_filter()``. Those rows can only be found through the org FK, so the + query has to run while ``organization_id`` is still set -- nulling it first would strand + them. +- It clears ``default_project`` from the user's settings, which points at a project in the + org being left and would otherwise be dangling on their next session. + +Two lookup quirks worth knowing before calling them: ``get_user_by_email`` compares with +``func.lower()`` on both sides, so it matches regardless of how the address was cased at +signup, and ``get_user_by_id`` returns ``None`` for a malformed UUID string rather than +raising -- a caller cannot tell "no such user" from "that wasn't a UUID". +""" + +import uuid +from typing import List, Optional, Union +from uuid import UUID + +from sqlalchemy import func +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from rhesis.backend.app import models, schemas +from rhesis.backend.app.auth.org_membership_hook import on_user_org_assigned +from rhesis.backend.app.models.project_membership import ProjectMembership +from rhesis.backend.app.scope import bypass_tenant_filter +from rhesis.backend.app.utils.crud_utils import get_item, get_items + + +def get_user( + db: Session, user_id: uuid.UUID, organization_id: str = None, tenant_user_id: str = None +) -> Optional[models.User]: + """Get user.""" + return get_item(db, models.User, user_id, organization_id, tenant_user_id) + + +def get_users( + db: Session, + skip: int = 0, + limit: int = 10, + sort_by: str = "created_at", + sort_order: str = "desc", + filter: str | None = None, + organization_id: str = None, + user_id: str = None, +) -> List[models.User]: + return get_items( + db, + models.User, + skip, + limit, + sort_by, + sort_order, + filter, + organization_id=organization_id, + user_id=user_id, + ) + + +def create_user(db: Session, user: schemas.UserCreate) -> models.User: + """Create a new user without RLS checks, because we're creating a new user that has no + organization_id""" + # Exclude fields not present on the User model + user_data = user.model_dump(exclude={"send_invite", "project_id"}) + db_user = models.User(**user_data) + db.add(db_user) + # Flush to get ID and other generated values before refresh + db.flush() + + # Seed the default org-role (EE) so the user is not locked out once RBAC is + # enabled for their org. No-op in community builds / when no org is set. + if db_user.organization_id is not None: + on_user_org_assigned(db, db_user.id, db_user.organization_id) + + # Transaction commit is handled by the session context manager + db.refresh(db_user) + return db_user + + +def update_user(db: Session, user_id: uuid.UUID, user: schemas.UserUpdate) -> Optional[models.User]: + """Update user with special handling for onboarding (no organization)""" + # Direct query without RLS filters for user updates + db_user = db.query(models.User).filter(models.User.id == user_id).first() + if not db_user: + return None + + # Update user attributes + user_data = user.model_dump(exclude_unset=True) + for key, value in user_data.items(): + setattr(db_user, key, value) + + # Transaction commit/rollback is handled by the session context manager + return db_user + + +def delete_user( + db: Session, target_user_id: uuid.UUID, organization_id: str, user_id: str +) -> Optional[models.User]: + """ + Remove a user from their organization by setting organization_id to NULL. + + The user account remains active but loses organization access. + This preserves the user account and all their data while removing + organizational context. On next login, the user will go through + the onboarding flow again. + + Also removes all project memberships within the org and clears + default_project so no orphaned rows or stale settings remain. + + Args: + db: Database session + target_user_id: ID of user to remove from organization + organization_id: Organization ID for tenant context + user_id: ID of the current user performing the action (for tenant context) + + Returns: + Updated user object or None if not found + + Raises: + ValueError: If user tries to delete themselves + """ + # Security check: Prevent users from deleting themselves + if str(target_user_id) == str(user_id): + raise ValueError("Users cannot remove themselves from the organization") + + # Get the user with tenant context + db_user = get_item(db, models.User, target_user_id, organization_id, user_id) + if db_user is None: + return None + + # Drop all project memberships within this org before nulling organization_id, + # while we can still identify them via the org FK. + with bypass_tenant_filter(): + memberships = ( + db.query(ProjectMembership) + .filter_by(user_id=target_user_id, organization_id=organization_id) + .all() + ) + for m in memberships: + db.delete(m) + + # Clear default_project — it's org-scoped so it would be stale after removal. + if db_user.settings.default_project is not None: + settings = db_user.settings.raw.copy() + settings.pop("default_project", None) + db_user.user_settings = settings + flag_modified(db_user, "user_settings") + + # Null the org FK last so the membership query above can still use it. + db_user.organization_id = None + + db.commit() + db.refresh(db_user) + + return db_user + + +def get_user_by_email(db: Session, email: str) -> Optional[models.User]: + """Look up a user by email, case-insensitively on both sides.""" + return db.query(models.User).filter(func.lower(models.User.email) == email.lower()).first() + + +def get_user_by_id(db: Session, user_id: Union[str, UUID]) -> Optional[models.User]: + """Retrieve a user by their ID. Accepts both string and UUID. + + Returns None for a malformed UUID string rather than raising. + """ + try: + # Convert string to UUID if it's a string + if isinstance(user_id, str): + user_id = UUID(user_id) + return db.query(models.User).filter(models.User.id == user_id).first() + except ValueError: + # Handle invalid UUID string + return None diff --git a/apps/backend/src/rhesis/backend/app/routers/auth.py b/apps/backend/src/rhesis/backend/app/routers/auth.py index 01c240dba7..fee4f6fd13 100644 --- a/apps/backend/src/rhesis/backend/app/routers/auth.py +++ b/apps/backend/src/rhesis/backend/app/routers/auth.py @@ -350,10 +350,11 @@ def accept_terms( current_user: User = Depends(require_current_user_or_token_without_context), ): """Record the authenticated user's acceptance of the current T&C version.""" - from rhesis.backend.app import crud from sqlalchemy.orm.attributes import flag_modified - user = crud.get_user_by_id(db, current_user.id) + from rhesis.backend.app.crud import user as user_crud + + user = user_crud.get_user_by_id(db, current_user.id) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -628,9 +629,9 @@ async def register_with_email( ) # The user was already created in register(), so look them up - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud - user = crud.get_user_by_email(db, body.email) + user = user_crud.get_user_by_email(db, body.email) if not user: raise HTTPException( @@ -714,10 +715,10 @@ async def verify_email( Verify a user's email address using the token from the verification email. """ - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud payload = verify_email_flow_token(body.token, "email_verification") - user = crud.get_user_by_email(db, payload["email"]) + user = user_crud.get_user_by_email(db, payload["email"]) # Enumeration-safe: return success even if user no longer exists if not user: @@ -755,9 +756,9 @@ def resend_verification( Resend the verification email. Always returns 200 to prevent email enumeration. """ - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud - user = crud.get_user_by_email(db, body.email) + user = user_crud.get_user_by_email(db, body.email) if user and not user.is_email_verified: try: @@ -796,9 +797,9 @@ def forgot_password( Request a password reset email. Always returns 200 to prevent email enumeration. """ - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud - user = crud.get_user_by_email(db, body.email) + user = user_crud.get_user_by_email(db, body.email) if user: try: @@ -834,7 +835,7 @@ async def reset_password( Reset a user's password using the token from the reset email. Token is single-use: once used, it cannot be used again. """ - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.utils.encryption import hash_password payload = verify_email_flow_token(body.token, "password_reset") @@ -859,7 +860,7 @@ async def reset_password( detail="Token already used or expired", ) - user = crud.get_user_by_email(db, payload["email"]) + user = user_crud.get_user_by_email(db, payload["email"]) if not user: raise HTTPException( @@ -908,10 +909,10 @@ def request_magic_link( doesn't exist yet (unified sign-in / sign-up flow). Always returns 200 to prevent email enumeration. """ - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.schemas.user import UserCreate - user = crud.get_user_by_email(db, body.email) + user = user_crud.get_user_by_email(db, body.email) is_new_user = False if not user: @@ -922,7 +923,7 @@ def request_magic_link( is_email_verified=False, is_active=True, ) - user = crud.create_user(db, user_data) + user = user_crud.create_user(db, user_data) db.commit() db.refresh(user) is_new_user = True @@ -969,7 +970,7 @@ async def verify_magic_link( Verify a magic link token and return a session token. Token is single-use: once used, it cannot be used again. """ - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud payload = verify_email_flow_token(body.token, "magic_link") jti = payload.get("jti") @@ -993,7 +994,7 @@ async def verify_magic_link( detail="Link already used or expired", ) - user = crud.get_user_by_email(db, payload["email"]) + user = user_crud.get_user_by_email(db, payload["email"]) if not user: raise HTTPException( @@ -1138,10 +1139,10 @@ def refresh_tokens( existed, whether they tripped reuse detection, etc. Detailed reasons go to structured logs and the audit stream. """ - from rhesis.backend.app import crud from rhesis.backend.app.auth.refresh_client_hook import ( get_refresh_client_minter, ) + from rhesis.backend.app.crud import user as user_crud # ``verify_and_refresh_token`` raises HTTPException with variant # detail strings (kept for backward compatibility with other call @@ -1156,7 +1157,7 @@ def refresh_tokens( raise _refresh_invalid() from exc raise - user = crud.get_user(db, str(token_row.user_id)) + user = user_crud.get_user(db, str(token_row.user_id)) if not user: # User vanished between mint and refresh (unusual; would # require the user to be hard-deleted). Same uniform 401. @@ -1388,7 +1389,7 @@ async def local_login(request: Request, db: Session = Depends(get_db_session)): ⚠️ WARNING: This endpoint is for QUICK START ONLY! It bypasses normal authentication and logs in as the default admin@local.dev user. """ - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.utils.quick_start import is_quick_start_enabled hostname = request.url.hostname if request.url.hostname is not None else None @@ -1406,7 +1407,7 @@ async def local_login(request: Request, db: Session = Depends(get_db_session)): logger.warning("⚠️ This should NEVER be used in production!") try: - user = crud.get_user_by_email(db, "admin@local.dev") + user = user_crud.get_user_by_email(db, "admin@local.dev") if not user: logger.error("QUICK START MODE user (admin@local.dev) not found in database") diff --git a/apps/backend/src/rhesis/backend/app/routers/user.py b/apps/backend/src/rhesis/backend/app/routers/user.py index 3dfaf6e473..228f123531 100644 --- a/apps/backend/src/rhesis/backend/app/routers/user.py +++ b/apps/backend/src/rhesis/backend/app/routers/user.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from rhesis.backend.app import crud, models, schemas +from rhesis.backend.app import models, schemas from rhesis.backend.app.auth.capabilities import Permission, capability from rhesis.backend.app.auth.principal import resolve_principal_from_request from rhesis.backend.app.auth.rbac import authorize @@ -14,6 +14,7 @@ require_current_user_or_token, require_current_user_or_token_without_context, ) +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.dependencies import ( get_db_session, get_tenant_context, @@ -136,7 +137,7 @@ def create_user( raise HTTPException(status_code=400, detail=str(e)) # Check for existing user with the same email - existing_user = crud.get_user_by_email(db, user.email) + existing_user = user_crud.get_user_by_email(db, user.email) if existing_user: # User already exists - check if they can be re-invited if existing_user.organization_id is not None: @@ -175,7 +176,7 @@ def create_user( send_invite = user.send_invite # Create the user (crud function will automatically exclude send_invite) - created_user = crud.create_user(db=db, user=user) + created_user = user_crud.create_user(db=db, user=user) # Note: new users are NOT auto-enrolled in any projects. # An admin must explicitly add them via the project Members tab. @@ -242,7 +243,7 @@ def read_users( ): """Get all users with their related objects""" organization_id, user_id = tenant_context - return crud.get_users( + return user_crud.get_users( db=db, skip=skip, limit=limit, @@ -354,7 +355,7 @@ def read_user( current_user: User = Depends(require_current_user_or_token), ): organization_id, user_id_tenant = tenant_context - db_user = crud.get_user( + db_user = user_crud.get_user( db, user_id=user_id, organization_id=organization_id, tenant_user_id=user_id_tenant ) if db_user is None: @@ -372,7 +373,7 @@ def delete_user( organization_id, user_id_tenant = tenant_context try: - db_user = crud.delete_user( + db_user = user_crud.delete_user( db, target_user_id=user_id, organization_id=organization_id, user_id=user_id_tenant ) if db_user is None: @@ -446,7 +447,7 @@ def update_user( # Get user with organization filtering (SECURITY CRITICAL) # During onboarding, organization_id may be None, which is acceptable - db_user = crud.get_user( + db_user = user_crud.get_user( db, user_id=user_id, organization_id=organization_id, tenant_user_id=user_id_tenant ) if db_user is None: @@ -469,7 +470,7 @@ def update_user( # only legitimate case is the org creator attaching to the org they own; # everything else (joining someone else's org, reassigning an existing org, # doing this on another user's behalf) is rejected. Leaving an org has its - # own dedicated endpoint and goes through crud.update_user unaffected since + # own dedicated endpoint and goes through user_crud.update_user unaffected since # it sets organization_id to None, not a new value. requested_org_id = getattr(user, "organization_id", None) if requested_org_id is not None and str(requested_org_id) != str(db_user.organization_id): @@ -486,7 +487,7 @@ def update_user( ) # Update the user - updated_user = crud.update_user(db, user_id=user_id, user=user) + updated_user = user_crud.update_user(db, user_id=user_id, user=user) # Joining an org for the first time (e.g. the creator attaching to their own # org during onboarding) — seed the default RBAC role now, not later. Every diff --git a/apps/backend/src/rhesis/backend/app/services/architect/runner.py b/apps/backend/src/rhesis/backend/app/services/architect/runner.py index c20ea7dc06..5575d46eed 100644 --- a/apps/backend/src/rhesis/backend/app/services/architect/runner.py +++ b/apps/backend/src/rhesis/backend/app/services/architect/runner.py @@ -126,8 +126,8 @@ async def build_agent( project_id: Optional[str] = None, ) -> tuple[Any, WebSocketEventHandler]: """Build the ArchitectAgent with tools and restore saved session state.""" - from rhesis.backend.app import crud from rhesis.backend.app.auth.token_utils import create_service_delegation_token + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.main import app as fastapi_app from rhesis.backend.app.mcp_server.local_tools import LocalToolProvider from rhesis.backend.app.utils.user_model_utils import get_user_generation_model @@ -136,7 +136,7 @@ async def build_agent( from rhesis.sdk.agents.tools import ExploreEndpointTool with get_db_with_tenant_variables(organization_id, user_id, project_id or "") as db: - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if not user: raise ValueError(f"User {user_id} not found") if not user.is_active: diff --git a/apps/backend/src/rhesis/backend/app/services/endpoint/files.py b/apps/backend/src/rhesis/backend/app/services/endpoint/files.py index 2dad182004..7c8febc00d 100644 --- a/apps/backend/src/rhesis/backend/app/services/endpoint/files.py +++ b/apps/backend/src/rhesis/backend/app/services/endpoint/files.py @@ -103,10 +103,10 @@ def enrich_files_with_extraction( resolved_model = None if db and user_id: try: - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.utils.user_model_utils import get_user_generation_model - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if user: resolved_model = resolve_model_for_extraction(get_user_generation_model(db, user)) except Exception as exc: diff --git a/apps/backend/src/rhesis/backend/app/services/endpoint/sdk_sync.py b/apps/backend/src/rhesis/backend/app/services/endpoint/sdk_sync.py index b60a3453bd..bb9079b02f 100644 --- a/apps/backend/src/rhesis/backend/app/services/endpoint/sdk_sync.py +++ b/apps/backend/src/rhesis/backend/app/services/endpoint/sdk_sync.py @@ -8,6 +8,7 @@ from sqlalchemy.orm.attributes import flag_modified from rhesis.backend.app import crud, models, schemas +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.models.enums import ( EndpointConfigSource, EndpointConnectionType, @@ -53,7 +54,7 @@ async def sync_sdk_endpoints( logger.info(f"Functions to sync: {len(functions_data)}") # Get user for language model access - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if not user: logger.error(f"User {user_id} not found for mapping generation") return {"created": 0, "updated": 0, "marked_inactive": 0, "errors": ["User not found"]} diff --git a/apps/backend/src/rhesis/backend/app/services/explorer/embeddings.py b/apps/backend/src/rhesis/backend/app/services/explorer/embeddings.py index 7d498ff3ed..e03865206e 100644 --- a/apps/backend/src/rhesis/backend/app/services/explorer/embeddings.py +++ b/apps/backend/src/rhesis/backend/app/services/explorer/embeddings.py @@ -12,8 +12,9 @@ import numpy as np from sqlalchemy.orm import Session -from rhesis.backend.app import crud, models, schemas +from rhesis.backend.app import models, schemas from rhesis.backend.app.crud import model as model_crud +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.crud.embedding import get_embedding_by_hash, mark_embeddings_stale from rhesis.backend.app.crud.explorer import ( get_default_embedding_model, @@ -126,7 +127,7 @@ def resolve_embedder(db: Session, user_id: str): result to :func:`generate_embedding_vector`, :func:`a_generate_embedding_vector`, or :func:`a_generate_embedding_vectors_batch` to avoid repeated DB lookups. """ - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if not user: raise ValueError(f"User not found: {user_id}") diff --git a/apps/backend/src/rhesis/backend/app/services/explorer/suggestions.py b/apps/backend/src/rhesis/backend/app/services/explorer/suggestions.py index 6e2196c419..2a7955332d 100644 --- a/apps/backend/src/rhesis/backend/app/services/explorer/suggestions.py +++ b/apps/backend/src/rhesis/backend/app/services/explorer/suggestions.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from rhesis.backend.app import crud +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.schemas.explorer import GenerateSuggestionsResponse, SuggestedTest from rhesis.backend.app.services.explorer.evaluation import ( EVAL_MAX_CONCURRENCY, @@ -63,7 +64,7 @@ def _get_generation_model(db: Session, user_id: str): ) try: - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if user: return get_user_generation_model(db, user) except Exception as e: diff --git a/apps/backend/src/rhesis/backend/app/services/organization.py b/apps/backend/src/rhesis/backend/app/services/organization.py index 6549af3f4b..dd2e7c4fe8 100644 --- a/apps/backend/src/rhesis/backend/app/services/organization.py +++ b/apps/backend/src/rhesis/backend/app/services/organization.py @@ -12,6 +12,7 @@ from rhesis.backend.app import crud, models from rhesis.backend.app.config.settings import get_application_settings +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.database import temporary_project_scope from rhesis.backend.app.models.enums import ModelType from rhesis.backend.app.models.metric import behavior_metric_association @@ -959,7 +960,7 @@ def execute_initial_test_runs(db: Session, organization_id: str, user_id: str) - try: # Fetch the User object print(f"Fetching user: {user_id}") - current_user = crud.get_user_by_id(db, user_id) + current_user = user_crud.get_user_by_id(db, user_id) if not current_user: print(f" ✗ User not found: {user_id}") result["details"].append({"status": "error", "message": f"User not found: {user_id}"}) diff --git a/apps/backend/src/rhesis/backend/app/services/task_notification.py b/apps/backend/src/rhesis/backend/app/services/task_notification.py index aadcf8314d..a3fa162cac 100644 --- a/apps/backend/src/rhesis/backend/app/services/task_notification.py +++ b/apps/backend/src/rhesis/backend/app/services/task_notification.py @@ -8,7 +8,8 @@ from sqlalchemy.orm import Session from rhesis.backend.app import models -from rhesis.backend.app.crud import get_status, get_type_lookup, get_user +from rhesis.backend.app.crud import get_status, get_type_lookup +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.notifications import EmailTemplate, email_service logger = logging.getLogger(__name__) @@ -30,7 +31,7 @@ def send_task_assignment_notification( """ try: # Get assignee details - assignee = get_user(db, task.assignee_id) if task.assignee_id else None + assignee = user_crud.get_user(db, task.assignee_id) if task.assignee_id else None if not assignee or not assignee.email: logger.warning( @@ -39,7 +40,7 @@ def send_task_assignment_notification( return False # Get creator details - creator = get_user(db, task.user_id) if task.user_id else None + creator = user_crud.get_user(db, task.user_id) if task.user_id else None # Get status details status = get_status(db, task.status_id) if task.status_id else None diff --git a/apps/backend/src/rhesis/backend/app/utils/user_model_utils.py b/apps/backend/src/rhesis/backend/app/utils/user_model_utils.py index bcbe0b43ee..ee494fe8f5 100644 --- a/apps/backend/src/rhesis/backend/app/utils/user_model_utils.py +++ b/apps/backend/src/rhesis/backend/app/utils/user_model_utils.py @@ -11,13 +11,13 @@ from sqlalchemy.orm import Session -from rhesis.backend.app import crud from rhesis.backend.app.config.settings import ( get_application_settings, get_model_settings, get_rhesis_settings, ) from rhesis.backend.app.crud import model as model_crud +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.models.user import User from rhesis.backend.app.services.platform_key import get_platform_api_key from rhesis.backend.app.utils.model_errors import ModelConfigurationError @@ -129,7 +129,7 @@ def get_evaluation_model(db: Session, user_id: str) -> Union[str, BaseLLM]: """ try: default_model = _default_evaluation_model() - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if user: return get_user_evaluation_model(db, user) logger.warning( @@ -174,7 +174,7 @@ def get_execution_model(db: Session, user_id: str) -> Union[str, BaseLLM]: """ try: default_model = _default_execution_model() - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if user: return get_user_execution_model(db, user) logger.warning( diff --git a/apps/backend/src/rhesis/backend/local_init.py b/apps/backend/src/rhesis/backend/local_init.py index dd4721ffda..43a1e8f197 100644 --- a/apps/backend/src/rhesis/backend/local_init.py +++ b/apps/backend/src/rhesis/backend/local_init.py @@ -14,8 +14,9 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from rhesis.backend.app import crud, models +from rhesis.backend.app import models from rhesis.backend.app.auth.terms import record_terms_acceptance +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.database import set_session_variables from rhesis.backend.app.services.organization import load_initial_data from rhesis.backend.app.utils.encryption import hash_token @@ -53,11 +54,11 @@ def initialize_local_environment(db: Session) -> None: logger.info("ℹ️ Local organization already exists, skipping initialization.") # Check if user exists - user = crud.get_user_by_email(db, "admin@local.dev") + user = user_crud.get_user_by_email(db, "admin@local.dev") if user: logger.info("ℹ️ Local user already exists.") - # Direct ORM construction below never goes through crud.create_user, + # Direct ORM construction below never goes through crud.user.create_user, # so the RBAC default-org-role hook never fired for pre-existing # local envs. Seed it now — no-op if RBAC is unavailable or the # row already exists. @@ -157,7 +158,7 @@ def initialize_local_environment(db: Session) -> None: record_terms_acceptance(user) db.flush() - # Direct ORM construction above bypasses crud.create_user, so the RBAC + # Direct ORM construction above bypasses crud.user.create_user, so the RBAC # default-org-role hook never fires on its own. owner_id is already set # on the org, so this resolves the admin to Owner (mirrors # routers/organization.py's post-onboarding hook invocation). @@ -203,7 +204,7 @@ def _ensure_local_admin_org_role( """Fire the RBAC default-org-role hook for the Quick Start admin user. ``initialize_local_environment`` builds the org/user via direct ORM - construction rather than ``crud.create_user``/the onboarding endpoint, so + construction rather than ``crud.user.create_user``/the onboarding endpoint, so the hook that seeds the ``organization_member`` row never runs on its own. No-op when RBAC is unavailable or a row already exists (idempotent). The EE handler (``assign_default_org_role``) logs the outcome itself, so core diff --git a/apps/backend/src/rhesis/backend/tasks/base.py b/apps/backend/src/rhesis/backend/tasks/base.py index dd138e5721..892775c414 100644 --- a/apps/backend/src/rhesis/backend/tasks/base.py +++ b/apps/backend/src/rhesis/backend/tasks/base.py @@ -318,11 +318,11 @@ def _get_user_info( Tuple of (email, name) or (None, None) if user not found """ try: - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud with self.get_db_session() as db: # Session variables are automatically set by get_db_session() - user = crud.get_user(db, user_id, organization_id=organization_id) + user = user_crud.get_user(db, user_id, organization_id=organization_id) if user: display_name = ( user.display_name diff --git a/apps/backend/src/rhesis/backend/tasks/embedding/graph.py b/apps/backend/src/rhesis/backend/tasks/embedding/graph.py index ec1dcf566e..e90a49fc92 100644 --- a/apps/backend/src/rhesis/backend/tasks/embedding/graph.py +++ b/apps/backend/src/rhesis/backend/tasks/embedding/graph.py @@ -162,10 +162,10 @@ def _run_embedding_graph( persist_graph: Callable[[Any, Any], None], parent_name: str, ) -> None: - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.services.embedding.graph_builder import build_2d_graph - user = crud.get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if user is None: logger.warning("Skipping graph computation: user not found", extra={"user_id": user_id}) return diff --git a/apps/backend/src/rhesis/backend/tasks/endpoint/explore.py b/apps/backend/src/rhesis/backend/tasks/endpoint/explore.py index 6035bdb236..1d180ef76d 100644 --- a/apps/backend/src/rhesis/backend/tasks/endpoint/explore.py +++ b/apps/backend/src/rhesis/backend/tasks/endpoint/explore.py @@ -13,7 +13,7 @@ import time from typing import Any, Dict, List, Literal, Optional -from rhesis.backend.app import crud +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.database import get_db_with_tenant_variables from rhesis.backend.app.utils.user_model_utils import get_user_generation_model from rhesis.backend.celery.core import app @@ -132,7 +132,7 @@ def _step( ) with get_db_with_tenant_variables(org_id or "", user_id or "", project_id or "") as db: - user = crud.get_user(db, user_id=user_id) + user = user_crud.get_user(db, user_id=user_id) if user is None: raise RuntimeError(f"User {user_id} not found") model = get_user_generation_model(db, user) diff --git a/apps/backend/src/rhesis/backend/tasks/execution/batch/context.py b/apps/backend/src/rhesis/backend/tasks/execution/batch/context.py index 1aa16ca265..bf86aacf6d 100644 --- a/apps/backend/src/rhesis/backend/tasks/execution/batch/context.py +++ b/apps/backend/src/rhesis/backend/tasks/execution/batch/context.py @@ -96,7 +96,7 @@ def prefetch_execution_context( trace_id: Optional[str] = None, ) -> ExecutionContext: """Pre-fetch all shared data in a single session before async execution.""" - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.database import bind_scope_to_session from rhesis.backend.app.models.behavior import Behavior from rhesis.backend.app.services.test_set import get_test_set @@ -146,7 +146,7 @@ def prefetch_execution_context( override_evaluation_model_id = attrs.get("evaluation_model_id") if user_id: - user = crud.get_user_by_id(session, user_id) + user = user_crud.get_user_by_id(session, user_id) if user: execution_model = get_execution_model_with_override( session, user, model_id=override_execution_model_id diff --git a/apps/backend/src/rhesis/backend/tasks/execution/sequential.py b/apps/backend/src/rhesis/backend/tasks/execution/sequential.py index 2f0af35a33..6753e9db3c 100644 --- a/apps/backend/src/rhesis/backend/tasks/execution/sequential.py +++ b/apps/backend/src/rhesis/backend/tasks/execution/sequential.py @@ -56,8 +56,8 @@ def execute_tests_sequentially( execution_model = None evaluation_model = None try: - from rhesis.backend.app import crud from rhesis.backend.app.config.settings import get_model_settings + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.utils.user_model_utils import ( get_evaluation_model_with_override, get_execution_model_with_override, @@ -71,7 +71,7 @@ def execute_tests_sequentially( seq_user_id = str(test_config.user_id) if test_config.user_id else None if seq_user_id: - user = crud.get_user_by_id(session, seq_user_id) + user = user_crud.get_user_by_id(session, seq_user_id) if user: execution_model = get_execution_model_with_override( session, user, model_id=override_execution_model_id diff --git a/apps/backend/src/rhesis/backend/tasks/test_set.py b/apps/backend/src/rhesis/backend/tasks/test_set.py index 0fd1f2d07c..960eb1e14d 100644 --- a/apps/backend/src/rhesis/backend/tasks/test_set.py +++ b/apps/backend/src/rhesis/backend/tasks/test_set.py @@ -3,6 +3,7 @@ from rhesis.backend.app import crud from rhesis.backend.app.constants import TestSetType +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.database import get_db_with_tenant_variables from rhesis.backend.app.models.test_set import TestSet from rhesis.backend.app.quota import QuotaResource @@ -199,7 +200,7 @@ def _resolve_generation_model( are visible to the ORM auto-filter. """ with get_db_with_tenant_variables(org_id, user_id, project_id) as db: - user = crud.get_user(db, user_id=user_id) + user = user_crud.get_user(db, user_id=user_id) if not user: raise ValueError(f"User not found: {user_id}") diff --git a/apps/polyphemus/src/rhesis/polyphemus/services/auth.py b/apps/polyphemus/src/rhesis/polyphemus/services/auth.py index 40787bd8b1..c323e8ae48 100644 --- a/apps/polyphemus/src/rhesis/polyphemus/services/auth.py +++ b/apps/polyphemus/src/rhesis/polyphemus/services/auth.py @@ -11,7 +11,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from rhesis.backend.app.auth.token_validation import validate_token -from rhesis.backend.app.crud import get_user_by_id +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.crud.token import get_token_by_value from rhesis.backend.app.database import get_db from rhesis.backend.app.models.user import User @@ -94,7 +94,7 @@ async def _validate_api_token(request: Request, token_value: str) -> User: # Retrieve the token and get associated user using backend CRUD utilities token = get_token_by_value(db, token_value) - user = get_user_by_id(db, token.user_id) if token else None + user = user_crud.get_user_by_id(db, token.user_id) if token else None if not user: logger.warning(f"User not found for token: {token_value[:10]}...") diff --git a/apps/polyphemus/src/rhesis/polyphemus/services/token_validator.py b/apps/polyphemus/src/rhesis/polyphemus/services/token_validator.py index b04787dd92..811b70cb86 100644 --- a/apps/polyphemus/src/rhesis/polyphemus/services/token_validator.py +++ b/apps/polyphemus/src/rhesis/polyphemus/services/token_validator.py @@ -7,7 +7,7 @@ from fastapi import HTTPException, Request, status from jwt import PyJWTError as JWTError -from rhesis.backend.app.crud import get_user_by_id +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.database import get_db from rhesis.backend.app.models.user import User @@ -71,7 +71,7 @@ def validate_delegation_token(request: Request, token: str) -> User: # Fetch from database with get_db() as db: - user = get_user_by_id(db, user_id) + user = user_crud.get_user_by_id(db, user_id) if not user or not user.is_active or not user.is_verified: raise HTTPException( diff --git a/ee/backend/src/rhesis/backend/ee/rbac/default_role.py b/ee/backend/src/rhesis/backend/ee/rbac/default_role.py index ad64b4cfc9..3792e4624d 100644 --- a/ee/backend/src/rhesis/backend/ee/rbac/default_role.py +++ b/ee/backend/src/rhesis/backend/ee/rbac/default_role.py @@ -1,7 +1,7 @@ """Default org-role assignment on user↔org association (EE handler). Registered with core's :func:`~rhesis.backend.app.auth.org_membership_hook` -registry at EE bootstrap. Core calls the hook from ``crud.create_user`` and the +registry at EE bootstrap. Core calls the hook from ``crud.user.create_user`` and the onboarding / re-invite paths; this handler writes the ``organization_member`` row that the :class:`~rhesis.backend.ee.rbac.provider.PermissionAuthorizationProvider` needs so the user is not denied everywhere once RBAC is enabled for their org. diff --git a/ee/backend/src/rhesis/backend/ee/sso/user_utils.py b/ee/backend/src/rhesis/backend/ee/sso/user_utils.py index c08a3de420..ea17f2cd81 100644 --- a/ee/backend/src/rhesis/backend/ee/sso/user_utils.py +++ b/ee/backend/src/rhesis/backend/ee/sso/user_utils.py @@ -48,9 +48,9 @@ def find_or_create_sso_user( Returns the User model instance on success. Raises SSOLoginError on any rejection. """ + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.models.user import User from rhesis.backend.app.schemas import user as user_schemas - from rhesis.backend.app import crud org_id = str(organization.id) @@ -150,7 +150,7 @@ def find_or_create_sso_user( last_login_at=current_time, organization_id=organization.id, ) - user = crud.create_user(db, user_data) + user = user_crud.create_user(db, user_data) from rhesis.backend.app.auth.user_utils import mark_user_joined_if_needed diff --git a/tests/backend/auth/test_joined_at.py b/tests/backend/auth/test_joined_at.py index 535a40fe31..360099b770 100644 --- a/tests/backend/auth/test_joined_at.py +++ b/tests/backend/auth/test_joined_at.py @@ -6,20 +6,20 @@ import pytest from sqlalchemy.orm import Session -from rhesis.backend.app import crud from rhesis.backend.app.auth.constants import AuthProviderType from rhesis.backend.app.auth.providers.base import AuthUser from rhesis.backend.app.auth.user_utils import ( find_or_create_user_from_auth, mark_user_joined_if_needed, ) +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.schemas import UserCreate @pytest.mark.unit class TestMarkUserJoinedIfNeeded: def test_sets_joined_at_when_user_has_org(self, test_db: Session, test_org_id: str): - user = crud.create_user( + user = user_crud.create_user( test_db, UserCreate( email=f"joined-{uuid.uuid4().hex[:8]}@example.com", @@ -34,7 +34,7 @@ def test_sets_joined_at_when_user_has_org(self, test_db: Session, test_org_id: s def test_is_idempotent(self, test_db: Session, test_org_id: str): when = datetime(2026, 7, 13, 10, 0, tzinfo=timezone.utc) - user = crud.create_user( + user = user_crud.create_user( test_db, UserCreate( email=f"joined-idem-{uuid.uuid4().hex[:8]}@example.com", @@ -48,7 +48,7 @@ def test_is_idempotent(self, test_db: Session, test_org_id: str): assert user.joined_at == when def test_no_op_without_organization(self, test_db: Session): - user = crud.create_user( + user = user_crud.create_user( test_db, UserCreate(email=f"no-org-{uuid.uuid4().hex[:8]}@example.com"), ) @@ -62,7 +62,7 @@ def test_no_op_without_organization(self, test_db: Session): class TestFindOrCreateUserFromAuthJoinedAt: def test_stamps_joined_at_for_invited_org_member(self, test_db: Session, test_org_id: str): email = f"invite-accept-{uuid.uuid4().hex[:8]}@example.com" - invited = crud.create_user( + invited = user_crud.create_user( test_db, UserCreate(email=email, organization_id=uuid.UUID(test_org_id)), ) diff --git a/tests/backend/crud/test_transaction_management.py b/tests/backend/crud/test_transaction_management.py index c86f262d6b..f28a188e89 100644 --- a/tests/backend/crud/test_transaction_management.py +++ b/tests/backend/crud/test_transaction_management.py @@ -33,6 +33,7 @@ from rhesis.backend.app import crud, models, schemas from rhesis.backend.app.constants import EntityType from rhesis.backend.app.crud import tag as tag_crud +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.crud.comment import add_emoji_reaction, remove_emoji_reaction from tests.backend.routes.fixtures.data_factories import ( CommentDataFactory, @@ -99,7 +100,7 @@ def test_update_user_commits_on_success(self, test_db: Session, authenticated_us update_data = schemas.UserUpdate(name=new_name) # Update user - result = crud.update_user(test_db, user_id, update_data) + result = user_crud.update_user(test_db, user_id, update_data) # Verify user was updated and persisted assert result is not None @@ -123,7 +124,7 @@ def test_create_user_commits_on_success(self, test_db: Session): user_create = schemas.UserCreate(**user_data) # Create user - result = crud.create_user(test_db, user_create) + result = user_crud.create_user(test_db, user_create) # Verify user was created and persisted assert result is not None diff --git a/tests/backend/ee/rbac/test_default_org_role.py b/tests/backend/ee/rbac/test_default_org_role.py index 45164374ff..8f3d20a215 100644 --- a/tests/backend/ee/rbac/test_default_org_role.py +++ b/tests/backend/ee/rbac/test_default_org_role.py @@ -1,6 +1,6 @@ """Tests for default org-role assignment on user↔org association (item 0). -Covers the RBAC-activation prerequisite: ``crud.create_user`` / onboarding / +Covers the RBAC-activation prerequisite: ``crud.user.create_user`` / onboarding / re-invite call the core ``on_user_org_assigned`` hook, and the EE handler (:func:`~rhesis.backend.ee.rbac.default_role.assign_default_org_role`) seeds an ``organization_member`` row so users are not locked out once RBAC is enabled. diff --git a/tests/backend/ee/sso/test_sso_user_utils.py b/tests/backend/ee/sso/test_sso_user_utils.py index 260485a4a8..8a0cb3a2af 100644 --- a/tests/backend/ee/sso/test_sso_user_utils.py +++ b/tests/backend/ee/sso/test_sso_user_utils.py @@ -179,7 +179,7 @@ def test_auto_provision_disabled_rejected(self): find_or_create_sso_user(db, auth_user, org, config) assert exc_info.value.reason_code == "auto_provision_disabled" - @patch("rhesis.backend.app.crud.create_user") + @patch("rhesis.backend.app.crud.user.create_user") def test_auto_provision_creates_user(self, mock_create_user): db = self._mock_db(query_results=[None, None]) org = _organization() diff --git a/tests/backend/fixtures/auth.py b/tests/backend/fixtures/auth.py index 4929355140..b6b96fcc23 100644 --- a/tests/backend/fixtures/auth.py +++ b/tests/backend/fixtures/auth.py @@ -33,7 +33,7 @@ def get_authenticated_user_info(db) -> tuple[str | None, str | None]: Tuple of (organization_id, user_id) as strings, or (None, None) if not found """ try: - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.crud.token import get_token_by_value except ImportError: return None, None @@ -49,7 +49,7 @@ def get_authenticated_user_info(db) -> tuple[str | None, str | None]: return None, None # Get user from the token's user_id - user = crud.get_user_by_id(db, token.user_id) + user = user_crud.get_user_by_id(db, token.user_id) if not user: return None, None diff --git a/tests/backend/fixtures/test_setup.py b/tests/backend/fixtures/test_setup.py index 81fd55606f..4d25998a7b 100644 --- a/tests/backend/fixtures/test_setup.py +++ b/tests/backend/fixtures/test_setup.py @@ -20,6 +20,7 @@ # Import backend modules from rhesis.backend.app import crud, models from rhesis.backend.app.auth.token_utils import generate_api_token +from rhesis.backend.app.crud import user as user_crud from rhesis.backend.app.crud.token import create_token from rhesis.backend.app.database import get_database_url from rhesis.backend.app.schemas import OrganizationCreate, UserCreate @@ -99,7 +100,7 @@ def create_test_user( last_login_at=datetime.now(timezone.utc), ) - user = crud.create_user(db, user_data) + user = user_crud.create_user(db, user_data) print(f"✅ Created test user: {user.email} (ID: {user.id})") return user @@ -176,7 +177,7 @@ def ensure_owner_membership(db: Session, organization_id: uuid.UUID, user_id: uu With RBAC available by default, every authenticated request from a test user is authorized against its ``organization_member`` role. Test users are - created via ``crud.create_user`` which fires the EE default-role hook + created via ``crud.user.create_user`` which fires the EE default-role hook *before* ``owner_id`` is set (the FK requires the user to exist first), so the hook always seeds them as **Member** — which lacks org-admin and project-create capabilities, causing wholesale 403s. This creates the row diff --git a/tests/backend/services/test_endpoint_files.py b/tests/backend/services/test_endpoint_files.py index a9058fdaa8..9123f44925 100644 --- a/tests/backend/services/test_endpoint_files.py +++ b/tests/backend/services/test_endpoint_files.py @@ -175,7 +175,7 @@ def test_model_forwarded_to_extract_with_vision_fallback(self): mock_user = MagicMock() with ( - patch("rhesis.backend.app.crud.get_user_by_id", return_value=mock_user), + patch("rhesis.backend.app.crud.user.get_user_by_id", return_value=mock_user), patch( "rhesis.backend.app.utils.user_model_utils.get_user_generation_model", return_value="openai/gpt-4o", diff --git a/tests/backend/tasks/architect/test_progress.py b/tests/backend/tasks/architect/test_progress.py index af8455a9df..414f068b04 100644 --- a/tests/backend/tasks/architect/test_progress.py +++ b/tests/backend/tasks/architect/test_progress.py @@ -196,10 +196,10 @@ class TestExplorationEmitsProgress: @patch("rhesis.backend.tasks.endpoint.explore.make_target_factory") @patch("rhesis.backend.tasks.endpoint.explore.get_db_with_tenant_variables") @patch("rhesis.backend.tasks.endpoint.explore.get_user_generation_model") - @patch("rhesis.backend.tasks.endpoint.explore.crud") + @patch("rhesis.backend.tasks.endpoint.explore.user_crud") def test_publishes_started_progress_and_completed( self, - mock_crud, + mock_user_crud, mock_get_model, mock_db_ctx, mock_make_factory, @@ -209,7 +209,7 @@ def test_publishes_started_progress_and_completed( ): from rhesis.backend.tasks.endpoint.explore import run_exploration_task - mock_crud.get_user.return_value = MagicMock() + mock_user_crud.get_user.return_value = MagicMock() mock_get_model.return_value = "vertex_ai/gemini-2.0-flash" mock_db_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_db_ctx.return_value.__exit__ = MagicMock(return_value=False) @@ -248,10 +248,10 @@ def test_publishes_started_progress_and_completed( @patch("rhesis.backend.tasks.endpoint.explore.make_target_factory") @patch("rhesis.backend.tasks.endpoint.explore.get_db_with_tenant_variables") @patch("rhesis.backend.tasks.endpoint.explore.get_user_generation_model") - @patch("rhesis.backend.tasks.endpoint.explore.crud") + @patch("rhesis.backend.tasks.endpoint.explore.user_crud") def test_skips_emission_when_task_id_is_empty( self, - mock_crud, + mock_user_crud, mock_get_model, mock_db_ctx, mock_make_factory, @@ -261,7 +261,7 @@ def test_skips_emission_when_task_id_is_empty( ): from rhesis.backend.tasks.endpoint.explore import run_exploration_task - mock_crud.get_user.return_value = MagicMock() + mock_user_crud.get_user.return_value = MagicMock() mock_get_model.return_value = "vertex_ai/gemini-2.0-flash" mock_db_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_db_ctx.return_value.__exit__ = MagicMock(return_value=False) @@ -289,10 +289,10 @@ def test_skips_emission_when_task_id_is_empty( @patch("rhesis.backend.tasks.endpoint.explore.make_target_factory") @patch("rhesis.backend.tasks.endpoint.explore.get_db_with_tenant_variables") @patch("rhesis.backend.tasks.endpoint.explore.get_user_generation_model") - @patch("rhesis.backend.tasks.endpoint.explore.crud") + @patch("rhesis.backend.tasks.endpoint.explore.user_crud") def test_emits_failed_event_when_tool_reports_failure( self, - mock_crud, + mock_user_crud, mock_get_model, mock_db_ctx, mock_make_factory, @@ -302,7 +302,7 @@ def test_emits_failed_event_when_tool_reports_failure( ): from rhesis.backend.tasks.endpoint.explore import run_exploration_task - mock_crud.get_user.return_value = MagicMock() + mock_user_crud.get_user.return_value = MagicMock() mock_get_model.return_value = "vertex_ai/gemini-2.0-flash" mock_db_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_db_ctx.return_value.__exit__ = MagicMock(return_value=False) @@ -423,10 +423,10 @@ class TestExplorationProjectIdFlow: @patch("rhesis.backend.tasks.endpoint.explore.make_target_factory") @patch("rhesis.backend.tasks.endpoint.explore.get_db_with_tenant_variables") @patch("rhesis.backend.tasks.endpoint.explore.get_user_generation_model") - @patch("rhesis.backend.tasks.endpoint.explore.crud") + @patch("rhesis.backend.tasks.endpoint.explore.user_crud") def test_passes_project_id_to_target_factory( self, - mock_crud, + mock_user_crud, mock_get_model, mock_db_ctx, mock_make_factory, @@ -436,7 +436,7 @@ def test_passes_project_id_to_target_factory( ): from rhesis.backend.tasks.endpoint.explore import run_exploration_task - mock_crud.get_user.return_value = MagicMock() + mock_user_crud.get_user.return_value = MagicMock() mock_get_model.return_value = "vertex_ai/gemini-2.0-flash" mock_db_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_db_ctx.return_value.__exit__ = MagicMock(return_value=False) diff --git a/tests/backend/tasks/test_explore.py b/tests/backend/tasks/test_explore.py index f8309c90f0..9933172a12 100644 --- a/tests/backend/tasks/test_explore.py +++ b/tests/backend/tasks/test_explore.py @@ -30,10 +30,10 @@ class TestRunExplorationTaskSuccess: @patch("rhesis.backend.tasks.endpoint.explore.make_target_factory") @patch("rhesis.backend.tasks.endpoint.explore.get_db_with_tenant_variables") @patch("rhesis.backend.tasks.endpoint.explore.get_user_generation_model") - @patch("rhesis.backend.tasks.endpoint.explore.crud") + @patch("rhesis.backend.tasks.endpoint.explore.user_crud") def test_returns_findings_dict( self, - mock_crud, + mock_user_crud, mock_get_model, mock_db_ctx, mock_make_factory, @@ -43,7 +43,7 @@ def test_returns_findings_dict( """Successful run should return a dict containing endpoint_id and strategy.""" from rhesis.backend.tasks.endpoint.explore import run_exploration_task - mock_crud.get_user.return_value = MagicMock() + mock_user_crud.get_user.return_value = MagicMock() mock_get_model.return_value = "vertex_ai/gemini-2.0-flash" mock_db_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_db_ctx.return_value.__exit__ = MagicMock(return_value=False) @@ -67,10 +67,10 @@ def test_returns_findings_dict( @patch("rhesis.backend.tasks.endpoint.explore.make_target_factory") @patch("rhesis.backend.tasks.endpoint.explore.get_db_with_tenant_variables") @patch("rhesis.backend.tasks.endpoint.explore.get_user_generation_model") - @patch("rhesis.backend.tasks.endpoint.explore.crud") + @patch("rhesis.backend.tasks.endpoint.explore.user_crud") def test_update_state_called( self, - mock_crud, + mock_user_crud, mock_get_model, mock_db_ctx, mock_make_factory, @@ -80,7 +80,7 @@ def test_update_state_called( """update_state should be called at least twice (start + strategy PROGRESS).""" from rhesis.backend.tasks.endpoint.explore import run_exploration_task - mock_crud.get_user.return_value = MagicMock() + mock_user_crud.get_user.return_value = MagicMock() mock_get_model.return_value = "vertex_ai/gemini-2.0-flash" mock_db_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_db_ctx.return_value.__exit__ = MagicMock(return_value=False) @@ -107,10 +107,10 @@ class TestRunExplorationTaskFailure: @patch("rhesis.backend.tasks.endpoint.explore.make_target_factory") @patch("rhesis.backend.tasks.endpoint.explore.get_db_with_tenant_variables") @patch("rhesis.backend.tasks.endpoint.explore.get_user_generation_model") - @patch("rhesis.backend.tasks.endpoint.explore.crud") + @patch("rhesis.backend.tasks.endpoint.explore.user_crud") def test_raises_on_tool_failure( self, - mock_crud, + mock_user_crud, mock_get_model, mock_db_ctx, mock_make_factory, @@ -120,7 +120,7 @@ def test_raises_on_tool_failure( """When ExploreEndpointTool reports failure, task should raise RuntimeError.""" from rhesis.backend.tasks.endpoint.explore import run_exploration_task - mock_crud.get_user.return_value = MagicMock() + mock_user_crud.get_user.return_value = MagicMock() mock_get_model.return_value = "vertex_ai/gemini-2.0-flash" mock_db_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_db_ctx.return_value.__exit__ = MagicMock(return_value=False) @@ -134,12 +134,12 @@ def test_raises_on_tool_failure( run_exploration_task.run(endpoint_id="ep-uuid", strategy="domain_probing") @patch("rhesis.backend.tasks.endpoint.explore.get_db_with_tenant_variables") - @patch("rhesis.backend.tasks.endpoint.explore.crud") - def test_raises_when_user_not_found(self, mock_crud, mock_db_ctx): + @patch("rhesis.backend.tasks.endpoint.explore.user_crud") + def test_raises_when_user_not_found(self, mock_user_crud, mock_db_ctx): """Task should raise when the user cannot be resolved.""" from rhesis.backend.tasks.endpoint.explore import run_exploration_task - mock_crud.get_user.return_value = None + mock_user_crud.get_user.return_value = None mock_db_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_db_ctx.return_value.__exit__ = MagicMock(return_value=False) diff --git a/tests/backend/tasks/test_test_set_model_override.py b/tests/backend/tasks/test_test_set_model_override.py index b838a1242c..269df49c2e 100644 --- a/tests/backend/tasks/test_test_set_model_override.py +++ b/tests/backend/tasks/test_test_set_model_override.py @@ -31,7 +31,7 @@ class TestResolveGenerationModel: """Tests for _resolve_generation_model.""" @patch("rhesis.backend.tasks.test_set.get_generation_model_with_override") - @patch("rhesis.backend.app.crud.get_user") + @patch("rhesis.backend.app.crud.user.get_user") @patch("rhesis.backend.tasks.test_set.get_db_with_tenant_variables") def test_resolves_user_default_when_no_model_id( self, mock_get_db, mock_get_user, mock_override @@ -51,7 +51,7 @@ def test_resolves_user_default_when_no_model_id( assert result == "user-default-model" @patch("rhesis.backend.tasks.test_set.get_generation_model_with_override") - @patch("rhesis.backend.app.crud.get_user") + @patch("rhesis.backend.app.crud.user.get_user") @patch("rhesis.backend.tasks.test_set.get_db_with_tenant_variables") def test_passes_through_override_model_id( self, mock_get_db, mock_get_user, mock_override @@ -75,7 +75,7 @@ def test_passes_through_override_model_id( assert result == "override-model" @patch("rhesis.backend.tasks.test_set.get_generation_model_with_override") - @patch("rhesis.backend.app.crud.get_user") + @patch("rhesis.backend.app.crud.user.get_user") @patch("rhesis.backend.tasks.test_set.get_db_with_tenant_variables") def test_passes_project_id_to_session( self, mock_get_db, mock_get_user, mock_override @@ -92,7 +92,7 @@ def test_passes_project_id_to_session( mock_get_db.assert_called_once_with("org-1", "user-1", "project-1") - @patch("rhesis.backend.app.crud.get_user") + @patch("rhesis.backend.app.crud.user.get_user") @patch("rhesis.backend.tasks.test_set.get_db_with_tenant_variables") def test_raises_when_user_not_found(self, mock_get_db, mock_get_user): from rhesis.backend.tasks.test_set import _resolve_generation_model diff --git a/tests/backend/utils/test_auth_utils.py b/tests/backend/utils/test_auth_utils.py index 8c64291ccd..5013e23fce 100644 --- a/tests/backend/utils/test_auth_utils.py +++ b/tests/backend/utils/test_auth_utils.py @@ -81,7 +81,7 @@ def test_get_current_user_with_session_user_found(self): request = Mock() request.session = {"user_id": "user123"} - # Mock get_db context manager and crud.get_user_by_id + # Mock get_db context manager and user_crud.get_user_by_id mock_db = Mock(spec=Session) @contextmanager @@ -90,7 +90,7 @@ def mock_get_db(): with patch("rhesis.backend.app.auth.user_utils.get_db", mock_get_db): with patch( - "rhesis.backend.app.auth.user_utils.crud.get_user_by_id", + "rhesis.backend.app.auth.user_utils.user_crud.get_user_by_id", return_value=mock_user, ) as mock_get_user: result = pytest.run(get_current_user(request)) @@ -108,7 +108,7 @@ def test_get_current_user_with_session_no_org(self): request = Mock() request.session = {"user_id": "user123"} - # Mock get_db context manager and crud.get_user_by_id + # Mock get_db context manager and user_crud.get_user_by_id mock_db = Mock(spec=Session) @contextmanager @@ -117,7 +117,7 @@ def mock_get_db(): with patch("rhesis.backend.app.auth.user_utils.get_db", mock_get_db): with patch( - "rhesis.backend.app.auth.user_utils.crud.get_user_by_id", + "rhesis.backend.app.auth.user_utils.user_crud.get_user_by_id", return_value=mock_user, ) as mock_get_user: result = pytest.run(get_current_user(request)) @@ -131,7 +131,7 @@ def test_get_current_user_user_not_found(self): request = Mock() request.session = {"user_id": "user123"} - # Mock get_db context manager and crud.get_user_by_id + # Mock get_db context manager and user_crud.get_user_by_id mock_db = Mock(spec=Session) @contextmanager @@ -140,7 +140,7 @@ def mock_get_db(): with patch("rhesis.backend.app.auth.user_utils.get_db", mock_get_db): with patch( - "rhesis.backend.app.auth.user_utils.crud.get_user_by_id", + "rhesis.backend.app.auth.user_utils.user_crud.get_user_by_id", return_value=None, ): result = pytest.run(get_current_user(request)) diff --git a/tests/backend/utils/test_user_utils.py b/tests/backend/utils/test_user_utils.py index 93a391a1b5..9a518328a7 100644 --- a/tests/backend/utils/test_user_utils.py +++ b/tests/backend/utils/test_user_utils.py @@ -70,7 +70,7 @@ async def test_get_current_user_with_organization(self, test_db: Session, test_o @pytest.mark.asyncio async def test_get_current_user_without_organization(self, test_db: Session): """Test get_current_user with user having no organization - should return None""" - from rhesis.backend.app import crud + from rhesis.backend.app.crud import user as user_crud # Create a user WITHOUT an organization (unique email for each run) user_data = UserCreate( @@ -83,7 +83,7 @@ async def test_get_current_user_without_organization(self, test_db: Session): organization_id=None, # No organization last_login_at=datetime.now(timezone.utc), ) - user = crud.create_user(test_db, user_data) + user = user_crud.create_user(test_db, user_data) test_db.commit() # Get user ID before the session closes