Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions apps/backend/src/rhesis/backend/app/auth/providers/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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),
Expand Down
12 changes: 6 additions & 6 deletions apps/backend/src/rhesis/backend/app/auth/user_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
154 changes: 0 additions & 154 deletions apps/backend/src/rhesis/backend/app/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading