Skip to content
Open
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
13 changes: 11 additions & 2 deletions backend/app/api/docs/organization/delete.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
Delete an organization.
Delete an organization. **Requires superuser access.**

Permanently deletes an organization and all associated data.
Supports two delete modes, selected by an optional request body:

```json
{ "hard_delete": false }
```

- **`hard_delete: false` (default)** — *Soft delete.* The organization is marked **inactive** and all of its projects are deactivated as well. No data is removed, so the organization can be reactivated later. It simply stops appearing in listings and can no longer be used. Omitting the body entirely also performs a soft delete.
- **`hard_delete: true`** — *Permanent delete.* The organization and everything owned by it — projects, collections, documents, credentials, assistants, fine-tunings, conversations, and user-project mappings — are permanently removed. **This cannot be undone.**

In both modes, user **accounts are never deleted** (a user may belong to other organizations). Any user left without an active project afterwards is marked **inactive** and can no longer log in until they are added to an active project again.
10 changes: 8 additions & 2 deletions backend/app/api/docs/organization/list.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
List all organizations.
List organizations. **Requires superuser access.**

Returns paginated list of all organizations in the system. The response includes a `has_more` field in `metadata` indicating whether additional pages are available.
Returns a paginated list of organizations. The response includes a `has_more` field in `metadata` indicating whether additional pages are available.

**Query Parameters:**
- `search` (optional): Case-insensitive substring match on the organization **name**. For example, `?search=acme` returns every organization whose name contains "acme".
- `is_active` (optional, default `true`): Filter by active status. Pass `false` to list soft-deleted organizations.
- `skip` (optional, default `0`): Number of records to skip for pagination.
- `limit` (optional, default `100`, max `100`): Maximum number of records to return.
13 changes: 11 additions & 2 deletions backend/app/api/docs/projects/delete.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
Delete a project.
Delete a project. **Requires superuser access.**

Permanently deletes a project and all associated data including documents, collections, and configurations.
Supports two delete modes, selected by an optional request body:

```json
{ "hard_delete": false }
```

- **`hard_delete: false` (default)** — *Soft delete.* The project is marked **inactive**. No data is removed, so the project can be reactivated later. It simply stops appearing in listings and can no longer be used. Omitting the body entirely also performs a soft delete.
- **`hard_delete: true`** — *Permanent delete.* The project and everything owned by it — collections, documents, credentials, assistants, fine-tunings, conversations, and user-project mappings — are permanently removed. **This cannot be undone.**

In both modes, user **accounts are never deleted** (a user may belong to other projects). Any user left without an active project afterwards is marked **inactive** and can no longer log in until they are added to an active project again.
10 changes: 8 additions & 2 deletions backend/app/api/docs/projects/list.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
List all projects.
List projects across all organizations. **Requires superuser access.**

Returns paginated list of all projects across all organizations.
Returns a paginated list of projects. The response includes a `has_more` field in `metadata` indicating whether additional pages are available.

**Query Parameters:**
- `search` (optional): Case-insensitive substring match on the project **name**. For example, `?search=onboarding` returns every project whose name contains "onboarding".
- `is_active` (optional, default `true`): Filter by active status. Pass `false` to list soft-deleted projects.
- `skip` (optional, default `0`): Number of records to skip for pagination.
- `limit` (optional, default `100`, max `100`): Maximum number of records to return.
8 changes: 6 additions & 2 deletions backend/app/api/docs/projects/list_by_org.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
List all projects for a given organization.
List projects for a given organization. **Requires superuser access.**

Returns all projects belonging to the specified organization ID. The organization must exist and be active.
Returns the projects belonging to the specified organization ID. The organization must exist and be active.

**Query Parameters:**
- `search` (optional): Case-insensitive substring match on the project **name**. For example, `?search=onboarding` returns only projects whose name contains "onboarding".
- `is_active` (optional, default `true`): Filter by active status. Pass `false` to list soft-deleted projects (e.g. to selectively reactivate them).
2 changes: 1 addition & 1 deletion backend/app/api/docs/user_project/delete.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ Remove a user from a project. **Requires superuser access.**
**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.
This only removes the user-project mapping — the user account itself is never deleted. If this was the user's last project, the account is marked **inactive** and can no longer log in until they are added to a project again. You cannot remove yourself from a project.
18 changes: 18 additions & 0 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse:

available_projects = get_user_accessible_projects(session=session, user_id=user.id)

if not user.is_superuser and not available_projects:
logger.info(
f"[google_auth] User has no accessible projects | user_id: {user.id}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not assigned to any active project. Please contact your administrator.",
)

if len(available_projects) == 1:
proj = available_projects[0]
logger.info(
Expand Down Expand Up @@ -389,6 +398,15 @@ def verify_magic_link(session: SessionDep, token: str) -> JSONResponse:
# Get user's projects to embed in token
available_projects = get_user_accessible_projects(session=session, user_id=user.id)

if not user.is_superuser and not available_projects:
logger.info(
f"[verify_magic_link] User has no accessible projects | user_id: {user.id}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not assigned to any active project. Please contact your administrator.",
)

organization_id = None
project_id = None
if len(available_projects) == 1:
Expand Down
18 changes: 13 additions & 5 deletions backend/app/api/routes/login.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import timedelta
from typing import Annotated, Any, Optional
from typing import Annotated, Any

from fastapi import APIRouter, Depends, HTTPException, Form
from fastapi import APIRouter, Depends, Form, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.security import OAuth2PasswordRequestForm

Expand All @@ -11,6 +11,7 @@
from app.core.config import settings
from app.core.security import get_password_hash
from app.crud import authenticate, get_user_by_email
from app.crud.auth import get_user_accessible_projects
from app.models import Message, NewPassword, Token, UserPublic
from app.utils import (
generate_password_reset_token,
Expand All @@ -26,9 +27,8 @@
def login_access_token(
session: SessionDep,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
token_expiry_minutes: Optional[int] = Form(
default=settings.ACCESS_TOKEN_EXPIRE_MINUTES, ge=1, le=60 * 24 * 360
),
token_expiry_minutes: int
| None = Form(default=settings.ACCESS_TOKEN_EXPIRE_MINUTES, ge=1, le=60 * 24 * 360),
) -> Token:
"""
OAuth2 compatible token login with customizable expiration time.
Expand All @@ -42,6 +42,14 @@ def login_access_token(
elif not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")

if not user.is_superuser and not get_user_accessible_projects(
session=session, user_id=user.id
):
raise HTTPException(
status_code=403,
detail="You are not assigned to any active project. Please contact your administrator.",
)

access_token_expires = timedelta(minutes=token_expiry_minutes)

return Token(
Expand Down
78 changes: 61 additions & 17 deletions backend/app/api/routes/organization.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import logging
from typing import List

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func
from sqlmodel import select

from app.api.deps import SessionDep
from app.api.permissions import Permission, require_permission
from app.crud.organization import (
cascade_deactivate_organization,
create_organization,
get_organization_by_id,
get_organization_by_name,
hard_delete_organization,
soft_delete_organization,
)
from app.models import (
DeleteRequest,
Organization,
OrganizationCreate,
OrganizationUpdate,
OrganizationPublic,
OrganizationUpdate,
)
from app.api.deps import SessionDep
from app.api.permissions import Permission, require_permission
from app.crud.organization import create_organization, get_organization_by_id
from app.utils import APIResponse, load_description

logger = logging.getLogger(__name__)
Expand All @@ -24,18 +31,30 @@
@router.get(
"",
dependencies=[Depends(require_permission(Permission.SUPERUSER))],
response_model=APIResponse[List[OrganizationPublic]],
response_model=APIResponse[list[OrganizationPublic]],
description=load_description("organization/list.md"),
)
def read_organizations(
session: SessionDep,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
) -> APIResponse[List[OrganizationPublic]]:
count_statement = select(func.count()).select_from(Organization)
search: str
| None = Query(
None, description="Case-insensitive substring match on the organization name"
),
is_active: bool = Query(
True,
description="Filter by active status. Pass false to list soft-deleted organizations.",
),
) -> APIResponse[list[OrganizationPublic]]:
filters = [Organization.is_active.is_(is_active)]
if search and search.strip():
filters.append(Organization.name.ilike(f"%{search.strip()}%"))

count_statement = select(func.count()).select_from(Organization).where(*filters)
count = session.exec(count_statement).one()

statement = select(Organization).offset(skip).limit(limit)
statement = select(Organization).where(*filters).offset(skip).limit(limit)
organizations = session.exec(statement).all()

has_more = (skip + limit) < count
Expand Down Expand Up @@ -69,7 +88,7 @@ def read_organization(
Retrieve an organization by ID.
"""
org = get_organization_by_id(session=session, org_id=org_id)
if org is None:
if org is None or not org.is_active:
raise HTTPException(status_code=404, detail="Organization not found")
return APIResponse.success_response(org)

Expand All @@ -89,11 +108,28 @@ def update_organization(
raise HTTPException(status_code=404, detail="Organization not found")

org_data = org_in.model_dump(exclude_unset=True)
org = org.model_copy(update=org_data)

new_name = org_data.get("name")
if new_name and new_name != org.name:
existing = get_organization_by_name(session=session, name=new_name)
if existing and existing.id != org.id:
raise HTTPException(
status_code=409,
detail="An organization with this name already exists",
)

target_active = org_data.get("is_active")
deactivating = target_active is False and org.is_active

org.sqlmodel_update(org_data)
session.add(org)
session.commit()
session.flush()

if deactivating:
cascade_deactivate_organization(session=session, organization=org)

session.commit()
session.refresh(org)
logger.info(
f"[update_organization] Organization Updated Successfully | 'org_id': {org.id}"
)
Expand All @@ -105,17 +141,25 @@ def update_organization(
"/{org_id}",
dependencies=[Depends(require_permission(Permission.SUPERUSER))],
response_model=APIResponse[None],
include_in_schema=False,
description=load_description("organization/delete.md"),
)
def delete_organization(session: SessionDep, org_id: int) -> APIResponse[None]:
def delete_organization_endpoint(
session: SessionDep,
org_id: int,
body: DeleteRequest | None = None,
) -> APIResponse[None]:
org = get_organization_by_id(session=session, org_id=org_id)
if org is None:
raise HTTPException(status_code=404, detail="Organization not found")

session.delete(org)
session.commit()
hard_delete = body.hard_delete if body else False
if hard_delete:
hard_delete_organization(session=session, organization=org)
else:
soft_delete_organization(session=session, organization=org)

logger.info(
f"[delete_organization] Organization Deleted Successfully | 'org_id': {org_id}"
f"[delete_organization_endpoint] Organization deleted | 'org_id': {org_id}, "
f"hard_delete: {hard_delete}"
)
return APIResponse.success_response(None)
Loading
Loading