From af31a75a8d6ea68dd2074dbfe04d2582cbca66df Mon Sep 17 00:00:00 2001 From: kartik Date: Sun, 9 Aug 2026 23:12:33 +0530 Subject: [PATCH 1/6] add require_admin dependency backed by Supabase role --- backend/app/core/auth.py | 16 ++++++++++++++++ backend/app/services/users_service.py | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 38cec83..09cba03 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -7,6 +7,7 @@ from jwt.exceptions import PyJWKClientError from app.core.config import settings +from app.services.users_service import get_user_role bearer_scheme = HTTPBearer(auto_error=False) _jwks_client: PyJWKClient | None = None @@ -84,6 +85,21 @@ async def verify_auth0_token( return _decode_and_verify(access_token) +async def require_admin( + payload: dict = Depends(verify_auth0_token), +) -> dict[str, Any]: + """ + Require the authenticated user to have the "admin" role in Supabase. + """ + if get_user_role(payload["sub"]) != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required", + ) + + return payload + + async def get_optional_auth0_token( credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), ) -> str | None: diff --git a/backend/app/services/users_service.py b/backend/app/services/users_service.py index e5c26de..401dca7 100644 --- a/backend/app/services/users_service.py +++ b/backend/app/services/users_service.py @@ -35,6 +35,23 @@ def sync_auth0_user(payload: dict) -> dict | None: return response.data[0] if response.data else None +def get_user_role(auth0_sub: str) -> str | None: + """ + Look up a synced user's role in Supabase. + """ + supabase = get_supabase_client() + + response = ( + supabase.table("users") + .select("role") + .eq("auth0_sub", auth0_sub) + .limit(1) + .execute() + ) + + return response.data[0].get("role") if response.data else None + + async def sync_current_user(payload: dict) -> dict: """ Ensure the user has a LiteLLM virtual key, then sync them into Supabase. From 9de727e6cc4cbcf2e3b0b41b7bacc3746f5ad67f Mon Sep 17 00:00:00 2001 From: kartik Date: Sun, 9 Aug 2026 23:13:09 +0530 Subject: [PATCH 2/6] restrict all /kb endpoints to admin users --- backend/app/routes/knowledge_base_router.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/app/routes/knowledge_base_router.py b/backend/app/routes/knowledge_base_router.py index f5f2957..0a27da2 100644 --- a/backend/app/routes/knowledge_base_router.py +++ b/backend/app/routes/knowledge_base_router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, UploadFile, File +from fastapi import APIRouter, Depends, UploadFile, File from app.controllers.knowledge_base_controller import ( create_collection_controller, get_files_controller, @@ -8,8 +8,10 @@ get_similar_controller, get_file_chunks_controller, ) +from app.core.auth import require_admin -router = APIRouter() +# All knowledge base endpoints are admin-only. +router = APIRouter(dependencies=[Depends(require_admin)]) @router.post("/create-collection") From 3ae7db777e8761e09fda2b4d4acc7862c478a334 Mon Sep 17 00:00:00 2001 From: kartik Date: Sun, 9 Aug 2026 23:14:09 +0530 Subject: [PATCH 3/6] add GET /users/me/role endpoint --- backend/app/controllers/users_controller.py | 18 +++++++++++++++++- backend/app/routes/users_router.py | 20 ++++++++++++++++++-- backend/app/schemas/users_schema.py | 4 ++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/backend/app/controllers/users_controller.py b/backend/app/controllers/users_controller.py index 2154d25..9883f4c 100644 --- a/backend/app/controllers/users_controller.py +++ b/backend/app/controllers/users_controller.py @@ -1,7 +1,7 @@ from fastapi import HTTPException from app.services.litellm_service import get_user_budget_info -from app.services.users_service import sync_current_user +from app.services.users_service import get_user_role, sync_current_user async def sync_current_user_controller( @@ -36,3 +36,19 @@ async def get_current_user_budget_controller(payload: dict) -> dict: ) return await get_user_budget_info(auth0_sub) + + +async def get_current_user_role_controller(payload: dict) -> dict: + """ + Fetch the authenticated user's role. + """ + + auth0_sub = payload.get("sub") + + if not auth0_sub: + raise HTTPException( + status_code=400, + detail="Missing Auth0 subject claim", + ) + + return {"role": get_user_role(auth0_sub)} diff --git a/backend/app/routes/users_router.py b/backend/app/routes/users_router.py index a7cc7e3..fbfea4e 100644 --- a/backend/app/routes/users_router.py +++ b/backend/app/routes/users_router.py @@ -2,10 +2,15 @@ from app.controllers.users_controller import ( get_current_user_budget_controller, + get_current_user_role_controller, sync_current_user_controller, ) from app.core.auth import verify_auth0_token -from app.schemas.users_schema import SyncUserResponse, UserBudgetResponse +from app.schemas.users_schema import ( + SyncUserResponse, + UserBudgetResponse, + UserRoleResponse, +) router = APIRouter() @@ -29,4 +34,15 @@ async def get_current_user_budget( Endpoint to retrieve authenticated user's LiteLLM spend and budget. """ - return await get_current_user_budget_controller(payload) \ No newline at end of file + return await get_current_user_budget_controller(payload) + + +@router.get("/users/me/role", response_model=UserRoleResponse) +async def get_current_user_role( + payload: dict = Depends(verify_auth0_token), +): + """ + Endpoint to retrieve the authenticated user's role. + """ + + return await get_current_user_role_controller(payload) \ No newline at end of file diff --git a/backend/app/schemas/users_schema.py b/backend/app/schemas/users_schema.py index 012f9b9..902c23b 100644 --- a/backend/app/schemas/users_schema.py +++ b/backend/app/schemas/users_schema.py @@ -24,3 +24,7 @@ class UserBudgetResponse(BaseModel): spend: float max_budget: Optional[float] = None remaining_budget: Optional[float] = None + + +class UserRoleResponse(BaseModel): + role: Optional[str] = None From 2c2f82970b7b04621ece19bcf228192fa8e43ce3 Mon Sep 17 00:00:00 2001 From: kartik Date: Sun, 9 Aug 2026 23:14:36 +0530 Subject: [PATCH 4/6] hide admin section from non-admin users --- frontend/components/app-sidebar.tsx | 95 ++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 29 deletions(-) diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index 0e7c1b1..a5e752a 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -50,10 +50,12 @@ export function AppSidebar() { const { state } = useSidebar(); const { user, isLoading: isUserLoading } = useUser(); const [budget, setBudget] = useState(null); + const [role, setRole] = useState(null); const isCollapsed = state === "collapsed"; useEffect(() => { if (isUserLoading || !user) { + setRole(null); return; } @@ -88,7 +90,38 @@ export function AppSidebar() { } }; + const fetchRole = async () => { + try { + const token = await getAccessToken(); + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + + if (!apiUrl) { + throw new Error("NEXT_PUBLIC_API_URL is not configured"); + } + + const response = await fetch(`${apiUrl}/users/me/role`, { + headers: { + Authorization: `Bearer ${token}`, + accept: "application/json", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Role request failed with status ${response.status}`); + } + + const data = (await response.json()) as { role: string | null }; + setRole(data.role); + } catch (error) { + if (!controller.signal.aborted) { + console.error("Failed to fetch role", error); + } + } + }; + fetchBudget(); + fetchRole(); return () => { controller.abort(); @@ -221,36 +254,40 @@ export function AppSidebar() { - + {role === "admin" && ( + <> + - {/* Admin Section */} - - {!isCollapsed && ( - - Admin - - )} - - - - - - - {!isCollapsed && Knowledge Base} - - - - - - + {/* Admin Section */} + + {!isCollapsed && ( + + Admin + + )} + + + + + + + {!isCollapsed && Knowledge Base} + + + + + + + + )} From c1fe033dd48bf56b1155d7872c581f84de506be4 Mon Sep 17 00:00:00 2001 From: kartik Date: Sun, 9 Aug 2026 23:14:50 +0530 Subject: [PATCH 5/6] send bearer token on knowledge base requests --- frontend/app/admin/knowledge-base/page.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/app/admin/knowledge-base/page.tsx b/frontend/app/admin/knowledge-base/page.tsx index f3bc505..457c555 100644 --- a/frontend/app/admin/knowledge-base/page.tsx +++ b/frontend/app/admin/knowledge-base/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useRef } from "react"; +import { getAccessToken } from "@auth0/nextjs-auth0/client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -45,8 +46,12 @@ export default function KnowledgeBasePage() { const fetchFiles = async () => { try { setLoading(true); + const token = await getAccessToken(); const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/kb/files`, { - headers: { accept: "application/json" }, + headers: { + accept: "application/json", + Authorization: `Bearer ${token}`, + }, }); if (!res.ok) throw new Error("Failed to fetch files"); const data = await res.json(); @@ -109,8 +114,10 @@ export default function KnowledgeBasePage() { const formData = new FormData(); formData.append("file", file); try { + const token = await getAccessToken(); const res = await fetch(endpoint, { method: "POST", + headers: { Authorization: `Bearer ${token}` }, body: formData, }); if (!res.ok) throw new Error("Failed to upload file"); @@ -133,11 +140,15 @@ export default function KnowledgeBasePage() { const handleDeleteFile = async (fileName: string) => { if (!confirm("Are you sure you want to delete this file?")) return; try { + const token = await getAccessToken(); const res = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/kb/files/${encodeURIComponent(fileName)}`, { method: "DELETE", - headers: { accept: "application/json" }, + headers: { + accept: "application/json", + Authorization: `Bearer ${token}`, + }, }, ); if (!res.ok) throw new Error("Failed to delete file"); @@ -159,10 +170,14 @@ export default function KnowledgeBasePage() { setSelectedFile(file); setFileContent({ content: "", loading: true, error: "" }); try { + const token = await getAccessToken(); const res = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/kb/files/${encodeURIComponent(file.name)}/chunks`, { - headers: { accept: "application/json" }, + headers: { + accept: "application/json", + Authorization: `Bearer ${token}`, + }, }, ); if (!res.ok) throw new Error("Failed to fetch file content"); From 5a20b8fb7e01549cba7519821680b6f3d188a868 Mon Sep 17 00:00:00 2001 From: kartik Date: Mon, 10 Aug 2026 00:53:07 +0530 Subject: [PATCH 6/6] add optional source_url and uploaded_at to upload chunk payloads --- .../controllers/knowledge_base_controller.py | 12 +++++++---- backend/app/routes/knowledge_base_router.py | 14 ++++++++----- .../app/services/knowledge_base_service.py | 21 +++++++++++++++---- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/backend/app/controllers/knowledge_base_controller.py b/backend/app/controllers/knowledge_base_controller.py index e8da3f7..356ca2a 100644 --- a/backend/app/controllers/knowledge_base_controller.py +++ b/backend/app/controllers/knowledge_base_controller.py @@ -44,7 +44,9 @@ async def get_files_controller(): raise HTTPException(status_code=500, detail=f"Error getting files: {str(e)}") -async def upload_pdf_controller(file: UploadFile = File(...)): +async def upload_pdf_controller( + file: UploadFile = File(...), source_url: Optional[str] = None +): """ Upload a PDF file to the knowledge base. """ @@ -63,7 +65,7 @@ async def upload_pdf_controller(file: UploadFile = File(...)): file_name = file.filename # Upload to knowledge base - result = upload_pdf_file(temp_file_path, file_name) + result = upload_pdf_file(temp_file_path, file_name, source_url) if result["status"] == "success": return JSONResponse(content=result, status_code=200) @@ -83,7 +85,9 @@ async def upload_pdf_controller(file: UploadFile = File(...)): pass # Ignore cleanup errors -async def upload_text_controller(file: UploadFile = File(...)): +async def upload_text_controller( + file: UploadFile = File(...), source_url: Optional[str] = None +): """ Upload a text file to the knowledge base. """ @@ -102,7 +106,7 @@ async def upload_text_controller(file: UploadFile = File(...)): file_name = file.filename # Upload to knowledge base - result = upload_text_file(temp_file_path, file_name) + result = upload_text_file(temp_file_path, file_name, source_url) if result["status"] == "success": return JSONResponse(content=result, status_code=200) diff --git a/backend/app/routes/knowledge_base_router.py b/backend/app/routes/knowledge_base_router.py index 0a27da2..22d9a0e 100644 --- a/backend/app/routes/knowledge_base_router.py +++ b/backend/app/routes/knowledge_base_router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, UploadFile, File +from fastapi import APIRouter, Depends, Form, UploadFile, File from app.controllers.knowledge_base_controller import ( create_collection_controller, get_files_controller, @@ -37,23 +37,27 @@ async def get_files_endpoint(): @router.post("/upload-pdf") -async def upload_pdf_endpoint(file: UploadFile = File(...)): +async def upload_pdf_endpoint( + file: UploadFile = File(...), source_url: str = Form(None) +): """ Upload a PDF file to the knowledge base. """ try: - return await upload_pdf_controller(file) + return await upload_pdf_controller(file, source_url) except Exception as e: raise e @router.post("/upload-text") -async def upload_text_endpoint(file: UploadFile = File(...)): +async def upload_text_endpoint( + file: UploadFile = File(...), source_url: str = Form(None) +): """ Upload a text file to the knowledge base. """ try: - return await upload_text_controller(file) + return await upload_text_controller(file, source_url) except Exception as e: raise e diff --git a/backend/app/services/knowledge_base_service.py b/backend/app/services/knowledge_base_service.py index e354d82..81bc034 100644 --- a/backend/app/services/knowledge_base_service.py +++ b/backend/app/services/knowledge_base_service.py @@ -1,5 +1,6 @@ import os import uuid +from datetime import datetime, timezone from markitdown import MarkItDown from typing import List, Dict, Any, Optional from app.core.config import settings @@ -62,12 +63,12 @@ def embed_text(text: str): def chunk_text(text: str): """ Chunk a text string into smaller chunks using LangChain RecursiveCharacterTextSplitter. - The chunk size is 1000 characters and the overlap is 200 characters. + The chunk size is 2000 characters and the overlap is 300 characters. Args: text (str): The text to chunk. """ - text_splitter = RecursiveCharacterTextSplitter(chunk_size=1250, chunk_overlap=250) + text_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=300) return text_splitter.split_text(text) @@ -114,7 +115,7 @@ def extract_text_from_pdf(file_path: str) -> str: raise Exception(f"Error extracting text from PDF: {str(e)}") -def upload_pdf_file(file_path: str, file_name: str = None): +def upload_pdf_file(file_path: str, file_name: str = None, source_url: str = None): """ Upload a PDF file to a collection in Qdrant. The file is converted to text and then chunked into smaller chunks. @@ -123,6 +124,7 @@ def upload_pdf_file(file_path: str, file_name: str = None): Args: file_path (str): Path to the PDF file to upload. file_name (str): Name to use for the file in the collection. If None, uses the original filename. + source_url (str): Optional source URL to record on each chunk. """ try: # Ensure collection exists @@ -138,6 +140,9 @@ def upload_pdf_file(file_path: str, file_name: str = None): # Chunk the text chunks = chunk_text(text) + # Shared by every chunk from this upload + uploaded_at = datetime.now(timezone.utc).isoformat() + # Embed and upload each chunk for i, chunk in enumerate(chunks): # Create unique point ID @@ -152,6 +157,8 @@ def upload_pdf_file(file_path: str, file_name: str = None): "chunk": chunk, "chunk_index": i, "total_chunks": len(chunks), + "source_url": source_url or "", + "uploaded_at": uploaded_at, } # Insert into Qdrant @@ -170,7 +177,7 @@ def upload_pdf_file(file_path: str, file_name: str = None): return {"status": "error", "message": f"Error uploading PDF file: {str(e)}"} -def upload_text_file(file_path: str, file_name: str = None): +def upload_text_file(file_path: str, file_name: str = None, source_url: str = None): """ Upload a text file to a collection in Qdrant. The file is converted to text and then chunked into smaller chunks. @@ -179,6 +186,7 @@ def upload_text_file(file_path: str, file_name: str = None): Args: file_path (str): Path to the text file to upload. file_name (str): Name to use for the file in the collection. If None, uses the original filename. + source_url (str): Optional source URL to record on each chunk. """ try: # Ensure collection exists @@ -195,6 +203,9 @@ def upload_text_file(file_path: str, file_name: str = None): # Chunk the text chunks = chunk_text(text) + # Shared by every chunk from this upload + uploaded_at = datetime.now(timezone.utc).isoformat() + # Embed and upload each chunk for i, chunk in enumerate(chunks): # Create unique point ID @@ -209,6 +220,8 @@ def upload_text_file(file_path: str, file_name: str = None): "chunk": chunk, "chunk_index": i, "total_chunks": len(chunks), + "source_url": source_url or "", + "uploaded_at": uploaded_at, } # Insert into Qdrant