From 57bff1a79f89f912d3d468e43da096c142f8552a Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:19:36 +0530 Subject: [PATCH] feat(project): add superuser settings update endpoint with validation --- .../projects/superuser_update_settings.md | 13 ++ backend/app/api/routes/project.py | 35 +++++ backend/app/tests/api/routes/test_project.py | 124 +++++++++++++++++- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/docs/projects/superuser_update_settings.md diff --git a/backend/app/api/docs/projects/superuser_update_settings.md b/backend/app/api/docs/projects/superuser_update_settings.md new file mode 100644 index 000000000..6f6ae5b1f --- /dev/null +++ b/backend/app/api/docs/projects/superuser_update_settings.md @@ -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. diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index db42a31fd..f168b0d40 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -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, @@ -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))], diff --git a/backend/app/tests/api/routes/test_project.py b/backend/app/tests/api/routes/test_project.py index ab0a2d4bd..f1978d037 100644 --- a/backend/app/tests/api/routes/test_project.py +++ b/backend/app/tests/api/routes/test_project.py @@ -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 @@ -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." + )