Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
60 changes: 60 additions & 0 deletions backend/app/alembic/versions/050_add_userproject_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Add userproject table

Revision ID: 050
Revises: 049
Create Date: 2026-04-01 12:17:42.165482

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "050"
down_revision = "049"
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"user_project",
sa.Column(
"user_id", sa.Integer(), nullable=False, comment="Reference to the user"
),
sa.Column(
"organization_id",
sa.Integer(),
nullable=False,
comment="Reference to the organization",
),
sa.Column(
"project_id",
sa.Integer(),
nullable=False,
comment="Reference to the project",
),
sa.Column(
"id",
sa.Integer(),
nullable=False,
comment="Unique identifier for the user-project mapping",
),
sa.Column(
"inserted_at",
sa.DateTime(),
nullable=False,
comment="Timestamp when the mapping was created",
),
sa.ForeignKeyConstraint(
["organization_id"], ["organization.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "project_id", name="uq_user_project"),
)


def downgrade():
op.drop_table("user_project")
46 changes: 42 additions & 4 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@
from pydantic import ValidationError
from sqlmodel import Session

from sqlmodel import and_, select

from app.core import security
from app.core.config import settings
from app.core.db import engine
from app.core.security import api_key_manager
from app.crud.organization import validate_organization
from app.crud.project import validate_project
from app.models import (
APIKey,
AuthContext,
Organization,
Project,
TokenPayload,
User,
UserProject,
)


Expand Down Expand Up @@ -64,10 +68,11 @@ def _authenticate_with_jwt(session: Session, token: str) -> AuthContext:
)

user = session.get(User, token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=403, detail="Inactive user")
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User access has been revoked",
)

organization: Organization | None = None
project: Project | None = None
Expand All @@ -77,6 +82,39 @@ def _authenticate_with_jwt(session: Session, token: str) -> AuthContext:
if token_data.project_id:
project = validate_project(session=session, project_id=token_data.project_id)

# Verify user still has access to this project
if project:
has_access = session.exec(
select(UserProject.id)
.where(
and_(
UserProject.user_id == user.id,
UserProject.project_id == project.id,
)
)
.limit(1)
).first()

if not has_access:
# Fallback: check APIKey table for backward compatibility
has_api_key = session.exec(
select(APIKey.id)
.where(
and_(
APIKey.user_id == user.id,
APIKey.project_id == project.id,
APIKey.is_deleted.is_(False),
)
)
.limit(1)
).first()

if not has_api_key:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User access to this project has been revoked",
)

return AuthContext(user=user, organization=organization, project=project)


Expand Down
17 changes: 17 additions & 0 deletions backend/app/api/docs/user_project/add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Add one or more users to a project by email. **Requires superuser access.**

**Request Body:**
- `organization_id` (required): The ID of the organization the project belongs to.
- `project_id` (required): The ID of the project to add users to.
- `users` (required): Array of user objects.
- `email` (required): User's email address.
- `full_name` (optional): User's full name.

**Examples:**
- **Single user**: `{"organization_id": 1, "project_id": 1, "users": [{"email": "user@gmail.com", "full_name": "User Name"}]}`
- **Multiple users**: `{"organization_id": 1, "project_id": 1, "users": [{"email": "a@gmail.com"}, {"email": "b@gmail.com"}]}`

**Behavior per email:**
- If the user does not exist, a new account is created with `is_active: false`. The user will be activated on their first Google login.
- If the user already exists and is already in this project, they are skipped.
- If the user exists but is not in this project, they are added.
9 changes: 9 additions & 0 deletions backend/app/api/docs/user_project/delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Remove a user from a project. **Requires superuser access.**

**Path Parameters:**
- `user_id` (required): The ID of the user to remove.

**Query Parameters:**
- `project_id` (required): The ID of the project to remove the user from.

This only removes the user-project mapping — the user account itself is not deleted. You cannot remove yourself from a project.
6 changes: 6 additions & 0 deletions backend/app/api/docs/user_project/list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
List all users that belong to a project.

**Query Parameters:**
- `project_id` (required): The ID of the project to list users for.

Returns user details including their active status — users added via invitation will have `is_active: false` until they complete their first Google login.
2 changes: 2 additions & 0 deletions backend/app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
responses,
private,
threads,
user_project,
users,
utils,
onboarding,
Expand Down Expand Up @@ -52,6 +53,7 @@
api_router.include_router(project.router)
api_router.include_router(responses.router)
api_router.include_router(threads.router)
api_router.include_router(user_project.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(fine_tuning.router)
Expand Down
38 changes: 30 additions & 8 deletions backend/app/api/routes/google_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Token,
TokenPayload,
User,
UserProject,
UserPublic,
)
from app.utils import APIResponse, load_description
Expand All @@ -34,8 +35,24 @@


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should we have commont auth.py route instead of google_auth.py?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done, updated.

def _get_user_projects(session: Session, user_id: int) -> list[dict]:
"""Query distinct org/project pairs for a user from active API keys."""
statement = (
"""Query distinct org/project pairs for a user from both UserProject and APIKey tables."""
# Query from UserProject table
from_user_project = (
select(Organization.id, Organization.name, Project.id, Project.name)
.select_from(UserProject)
.join(Organization, Organization.id == UserProject.organization_id)
.join(Project, Project.id == UserProject.project_id)
.where(
and_(
UserProject.user_id == user_id,
Organization.is_active.is_(True),
Project.is_active.is_(True),
)
)
)

# Query from APIKey table (backward compatibility)
from_api_key = (
select(Organization.id, Organization.name, Project.id, Project.name)
.select_from(APIKey)
.join(Organization, Organization.id == APIKey.organization_id)
Expand All @@ -48,9 +65,12 @@ def _get_user_projects(session: Session, user_id: int) -> list[dict]:
Project.is_active.is_(True),
)
)
.distinct()
)
results = session.exec(statement).all()

# Union both queries and deduplicate
combined = from_user_project.union(from_api_key)
results = session.exec(combined).all()

return [
{
"organization_id": org_id,
Expand Down Expand Up @@ -187,11 +207,13 @@ def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse:
detail="No account found for this Google email. Please Contact Support to add your account.",
)

# Activate user on first Google login
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user account",
)
user.is_active = True
session.add(user)
session.commit()
session.refresh(user)
logger.info(f"[google_auth] User activated on first login | user_id: {user.id}")

google_profile = {
"email": idinfo.get("email"),
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/routes/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ def update_project(*, session: SessionDep, project_id: int, project_in: ProjectU
raise HTTPException(status_code=404, detail="Project not found")

project_data = project_in.model_dump(exclude_unset=True)
project = project.model_copy(update=project_data)
project.sqlmodel_update(project_data)

session.add(project)
session.commit()
session.flush()
session.refresh(project)
logger.info(
f"[update_project] Project updated successfully | project_id={project.id}"
)
Expand Down
Loading