Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
74d5cfc
config routes updated
vprashrex Mar 27, 2026
bd48eb8
code update
vprashrex Mar 27, 2026
10fd7c3
made query optional
vprashrex Mar 27, 2026
f73c22a
code updated
vprashrex Mar 27, 2026
0c7175b
added has_more functionality
vprashrex Mar 27, 2026
a363f45
refactor: update config CRUD methods to include query parameter and h…
vprashrex Mar 30, 2026
abd1fc8
Add projects-by-org endpoint and pagination for organizations list
vprashrex Mar 30, 2026
84ce2e8
Enhance organization validation: return 503 status code for inactive …
vprashrex Mar 31, 2026
6144d98
feat(*): google integration flow
Ayush8923 Mar 31, 2026
7f98414
Merge branch 'main' into feat/adding-query-params-to-config
Ayush8923 Mar 31, 2026
a7e9139
Merge branch 'feat/adding-query-params-to-config' of https://github.c…
Ayush8923 Mar 31, 2026
4cf576b
fix(*): update the js comment
Ayush8923 Mar 31, 2026
0b3b30e
fix(*): update the uv.lock
Ayush8923 Mar 31, 2026
6731b1d
Merge branch 'main' of https://github.com/ProjectTech4DevAI/kaapi-bac…
Ayush8923 Apr 1, 2026
6bb875c
fix(*): update the test cases
Ayush8923 Apr 1, 2026
3a1c9c8
fix(*): update test coverage
Ayush8923 Apr 1, 2026
b22588f
fix(*): update the test cases
Ayush8923 Apr 1, 2026
d1c9416
Merge branch 'main' into feat/google-integration-auth-flow
Ayush8923 Apr 4, 2026
5b8af37
fix(*): for the response used the APIResponses utils function
Ayush8923 Apr 4, 2026
b3eb1fd
fix(*): update the test cases
Ayush8923 Apr 4, 2026
8166675
Merge branch 'main' into feat/google-integration-auth-flow
Ayush8923 Apr 7, 2026
74d252b
Add/Delete User in Org/Project (#737)
Ayush8923 Apr 9, 2026
ac700af
Merge branch 'main' into feat/google-integration-auth-flow
Ayush8923 Apr 9, 2026
71720a3
fix(*): update the test cases
Ayush8923 Apr 9, 2026
d9c00c2
fix(*): added the test cases for user project
Ayush8923 Apr 9, 2026
43ab224
Merge branch 'main' into feat/google-integration-auth-flow
Ayush8923 Apr 10, 2026
2346448
fix(*): fix the openai_config test cases
Ayush8923 Apr 10, 2026
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")
145 changes: 109 additions & 36 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,26 @@
import jwt
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from pydantic import ValidationError
from sqlmodel import Session, select
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 All @@ -35,57 +42,123 @@ def get_db() -> Generator[Session, None, None]:
TokenDep = Annotated[str, Depends(reusable_oauth2)]


def _authenticate_with_jwt(session: Session, token: str) -> AuthContext:
"""Validate a JWT token and return the authenticated user context."""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
)
except (InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)

# Reject refresh tokens — they should only be used at /auth/refresh
if token_data.type == "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh tokens cannot be used for API access",
)

user = session.get(User, token_data.sub)
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

if token_data.org_id:
organization = validate_organization(session=session, org_id=token_data.org_id)
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)


def get_auth_context(
request: Request,
session: SessionDep,
token: TokenDep,
api_key: Annotated[str, Depends(api_key_header)],
) -> AuthContext:
"""
Verify valid authentication (API Key or JWT token) and return authenticated user context.
Verify valid authentication (API Key, JWT token, or cookie) and return authenticated user context.
Returns AuthContext with user info, project_id, and organization_id.
Authorization logic should be handled in routes.

Authentication priority:
1. X-API-KEY header
2. Authorization: Bearer <token> header
3. access_token cookie
"""
# 1. Try X-API-KEY header
if api_key:
auth_context = api_key_manager.verify(session, api_key)
if not auth_context:
raise HTTPException(status_code=401, detail="Invalid API Key")
if auth_context:
if not auth_context.user.is_active:
raise HTTPException(status_code=403, detail="Inactive user")

if not auth_context.user.is_active:
raise HTTPException(status_code=403, detail="Inactive user")
if not auth_context.organization.is_active:
raise HTTPException(status_code=403, detail="Inactive Organization")

if not auth_context.organization.is_active:
raise HTTPException(status_code=403, detail="Inactive Organization")
if not auth_context.project.is_active:
raise HTTPException(status_code=403, detail="Inactive Project")

if not auth_context.project.is_active:
raise HTTPException(status_code=403, detail="Inactive Project")
return auth_context

return auth_context
# 2. Try Authorization: Bearer <token> header
if token:
return _authenticate_with_jwt(session, token)

elif token:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)

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")

auth_context = AuthContext(
user=user,
)
return auth_context
# 3. Try access_token cookie
cookie_token = request.cookies.get("access_token")
if cookie_token:
return _authenticate_with_jwt(session, cookie_token)

else:
raise HTTPException(status_code=401, detail="Invalid Authorization format")
raise HTTPException(status_code=401, detail="Invalid Authorization format")


AuthContextDep = Annotated[AuthContext, Depends(get_auth_context)]
40 changes: 40 additions & 0 deletions backend/app/api/docs/auth/google.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Google OAuth Authentication

Authenticate a user via Google Sign-In by verifying the Google ID token.

## Request

- **token** (required): The Google ID token obtained from the frontend Google Sign-In flow.

## Behavior

1. Verifies the Google ID token against Google's public keys and the configured `GOOGLE_CLIENT_ID`.
2. Extracts user information (email, name, picture) from the verified token.
3. Looks up the user by email in the database.
4. If the user exists and was inactive (first login), activates the account.
5. Generates a JWT access token and refresh token, set as **HTTP-only secure cookies**.
6. If the user has exactly one project, it is auto-selected and embedded in the JWT.
7. If the user has multiple projects, `requires_project_selection: true` is returned with the list.

## Response Format

All responses follow the standard `APIResponse` format:
```json
{
"success": true,
"data": {
"access_token": "...",
"token_type": "bearer",
"user": { ... },
"google_profile": { ... },
"requires_project_selection": false,
"available_projects": [ ... ]
}
}
```

## Error Responses

- **400**: Invalid or expired Google token, or email not verified by Google.
- **401**: No account found for the Google email address.
- **500**: `GOOGLE_CLIENT_ID` is not configured.
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.
4 changes: 4 additions & 0 deletions backend/app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
config,
doc_transformation_job,
documents,
auth,
login,
languages,
llm,
Expand All @@ -17,6 +18,7 @@
responses,
private,
threads,
user_project,
users,
utils,
onboarding,
Expand All @@ -39,6 +41,7 @@
api_router.include_router(cron.router)
api_router.include_router(documents.router)
api_router.include_router(doc_transformation_job.router)
api_router.include_router(auth.router)
api_router.include_router(evaluations.router)
api_router.include_router(languages.router)
api_router.include_router(llm.router)
Expand All @@ -50,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
Loading
Loading