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: 13 additions & 0 deletions backend/app/api/docs/projects/superuser_update_settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Update settings for a project by ID.

Patches the `settings` JSONB of the project identified by the path `project_id`. Only the
keys provided in the request body are changed; existing keys are kept.

**Settings**

- `tracing` (bool): enable/disable Langfuse tracing for this project. Off by default to
conserve the org's Langfuse rate-limit/credit budget. Gates tracing for both the
response path and evaluations; when off, evaluations fall back to cosine-only scoring.

**Scope:** superusers may patch any project across organizations. A project-scoped key may
patch only its own bound project; targeting any other `project_id` returns 403.
35 changes: 35 additions & 0 deletions backend/app/api/routes/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
hard_delete_project,
soft_delete_project,
update_project_settings,
validate_project,
)
from app.crud.user_project import (
deactivate_users_without_projects,
Expand Down Expand Up @@ -105,6 +106,40 @@ def update_project_settings_route(
return APIResponse.success_response(project)


@router.patch(
"/{project_id}/settings",
response_model=APIResponse[ProjectPublic],
description=load_description("projects/superuser_update_settings.md"),
)
def update_project_settings_by_id_route(
*,
session: SessionDep,
auth_context: AuthContextDep,
project_id: int,
settings_in: ProjectSettingsUpdate,
) -> APIResponse[ProjectPublic]:
# Superusers patch any project; otherwise the key may only touch its own.
if not auth_context.user.is_superuser and (
auth_context.project is None or auth_context.project.id != project_id
):
raise HTTPException(
status_code=403,
detail="Insufficient permissions - require superuser or matching project access.",
)

settings_patch = settings_in.model_dump(exclude_unset=True)
if not settings_patch:
raise HTTPException(status_code=400, detail="No settings provided")

validate_project(session=session, project_id=project_id)
project = update_project_settings(
session=session,
project_id=project_id,
settings_patch=settings_patch,
)
return APIResponse.success_response(project)


@router.get(
"/{project_id}",
dependencies=[Depends(require_permission(Permission.SUPERUSER))],
Expand Down
124 changes: 123 additions & 1 deletion backend/app/tests/api/routes/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
from sqlmodel import Session

from app.core.config import settings
from app.crud.project import create_project, get_project_by_id
from app.crud.project import (
create_project,
get_project_by_id,
update_project_settings,
)
from app.main import app
from app.models import Organization, Project, ProjectCreate
from app.tests.utils.auth import TestAuthContext
Expand Down Expand Up @@ -355,3 +359,121 @@ def test_update_project_settings_route_empty_body_400(

assert response.status_code == 400
assert response.json()["error"] == "No settings provided"


def test_update_project_settings_by_id_superuser_other_org(
client: TestClient,
db: Session,
superuser_token_headers: dict[str, str],
) -> None:
org = create_test_organization(db)
project = _make_project(db, org, random_lower_string(), is_active=True)

response = client.patch(
f"{settings.API_V1_STR}/projects/{project.id}/settings",
json={"tracing": True},
headers=superuser_token_headers,
)

assert response.status_code == 200
assert response.json()["data"]["settings"]["tracing"] is True

db.expire_all()
refreshed = get_project_by_id(session=db, project_id=project.id)
assert refreshed.settings["tracing"] is True


def test_update_project_settings_by_id_merges_existing_keys(
client: TestClient,
db: Session,
superuser_token_headers: dict[str, str],
) -> None:
org = create_test_organization(db)
project = _make_project(db, org, random_lower_string(), is_active=True)
update_project_settings(
session=db,
project_id=project.id,
settings_patch={"existing_flag": "keep-me"},
)

response = client.patch(
f"{settings.API_V1_STR}/projects/{project.id}/settings",
json={"tracing": True},
headers=superuser_token_headers,
)

assert response.status_code == 200
result_settings = response.json()["data"]["settings"]
assert result_settings["existing_flag"] == "keep-me"
assert result_settings["tracing"] is True


def test_update_project_settings_by_id_empty_body_400(
client: TestClient,
db: Session,
superuser_token_headers: dict[str, str],
) -> None:
org = create_test_organization(db)
project = _make_project(db, org, random_lower_string(), is_active=True)

response = client.patch(
f"{settings.API_V1_STR}/projects/{project.id}/settings",
json={},
headers=superuser_token_headers,
)

assert response.status_code == 400
assert response.json()["error"] == "No settings provided"


def test_update_project_settings_by_id_not_found_404(
client: TestClient,
superuser_token_headers: dict[str, str],
) -> None:
response = client.patch(
f"{settings.API_V1_STR}/projects/999999/settings",
json={"tracing": True},
headers=superuser_token_headers,
)

assert response.status_code == 404
assert response.json()["error"] == "Project not found"


def test_update_project_settings_by_id_inactive_404(
client: TestClient,
db: Session,
superuser_token_headers: dict[str, str],
) -> None:
org = create_test_organization(db)
project = _make_project(db, org, random_lower_string(), is_active=False)

response = client.patch(
f"{settings.API_V1_STR}/projects/{project.id}/settings",
json={"tracing": True},
headers=superuser_token_headers,
)

assert response.status_code == 404
assert response.json()["error"] == "Project is not active"


def test_update_project_settings_by_id_non_superuser_other_project_403(
client: TestClient,
db: Session,
normal_user_token_headers: dict[str, str],
) -> None:
org = create_test_organization(db)
other_project = _make_project(db, org, random_lower_string(), is_active=True)

response = client.patch(
f"{settings.API_V1_STR}/projects/{other_project.id}/settings",
json={"tracing": True},
headers=normal_user_token_headers,
)

assert response.status_code == 403
assert (
response.json()["error"]
== "Insufficient permissions - require superuser or matching project access."
)
Loading