mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: Implement Role-Based Access Control (RBAC) for search space resources.
-Introduce granular permissions for documents, chats, podcasts, and logs. - Update routes to enforce permission checks for creating, reading, updating, and deleting resources. - Refactor user and search space interactions to align with RBAC model, removing ownership checks in favor of permission validation.
This commit is contained in:
parent
1ed0cb3dfe
commit
e9d32c3516
38 changed files with 5916 additions and 657 deletions
|
|
@ -15,12 +15,14 @@ from .llm_config_routes import router as llm_config_router
|
|||
from .logs_routes import router as logs_router
|
||||
from .luma_add_connector_route import router as luma_add_connector_router
|
||||
from .podcasts_routes import router as podcasts_router
|
||||
from .rbac_routes import router as rbac_router
|
||||
from .search_source_connectors_routes import router as search_source_connectors_router
|
||||
from .search_spaces_routes import router as search_spaces_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(search_spaces_router)
|
||||
router.include_router(rbac_router) # RBAC routes for roles, members, invites
|
||||
router.include_router(documents_router)
|
||||
router.include_router(podcasts_router)
|
||||
router.include_router(chats_router)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import Chat, SearchSpace, User, UserSearchSpacePreference, get_async_session
|
||||
from app.db import (
|
||||
Chat,
|
||||
Permission,
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas import (
|
||||
AISDKChatRequest,
|
||||
ChatCreate,
|
||||
|
|
@ -16,7 +23,7 @@ from app.schemas import (
|
|||
)
|
||||
from app.tasks.stream_connector_search_results import stream_connector_search_results
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission
|
||||
from app.utils.validators import (
|
||||
validate_connectors,
|
||||
validate_document_ids,
|
||||
|
|
@ -59,45 +66,38 @@ async def handle_chat_data(
|
|||
# print("RESQUEST DATA:", request_data)
|
||||
# print("SELECTED CONNECTORS:", selected_connectors)
|
||||
|
||||
# Check if the search space belongs to the current user
|
||||
# Check if the user has chat access to the search space
|
||||
try:
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
language_result = await session.execute(
|
||||
select(UserSearchSpacePreference)
|
||||
.options(
|
||||
selectinload(UserSearchSpacePreference.search_space).selectinload(
|
||||
SearchSpace.llm_configs
|
||||
),
|
||||
# Note: Removed selectinload for LLM relationships as they no longer exist
|
||||
# Global configs (negative IDs) don't have foreign keys
|
||||
# LLM configs are now fetched manually when needed
|
||||
)
|
||||
.filter(
|
||||
UserSearchSpacePreference.search_space_id == search_space_id,
|
||||
UserSearchSpacePreference.user_id == user.id,
|
||||
)
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.CHATS_CREATE.value,
|
||||
"You don't have permission to use chat in this search space",
|
||||
)
|
||||
user_preference = language_result.scalars().first()
|
||||
# print("UserSearchSpacePreference:", user_preference)
|
||||
|
||||
# Get search space with LLM configs (preferences are now stored at search space level)
|
||||
search_space_result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.options(selectinload(SearchSpace.llm_configs))
|
||||
.filter(SearchSpace.id == search_space_id)
|
||||
)
|
||||
search_space = search_space_result.scalars().first()
|
||||
|
||||
language = None
|
||||
llm_configs = [] # Initialize to empty list
|
||||
|
||||
if (
|
||||
user_preference
|
||||
and user_preference.search_space
|
||||
and user_preference.search_space.llm_configs
|
||||
):
|
||||
llm_configs = user_preference.search_space.llm_configs
|
||||
if search_space and search_space.llm_configs:
|
||||
llm_configs = search_space.llm_configs
|
||||
|
||||
# Manually fetch LLM configs since relationships no longer exist
|
||||
# Check fast_llm, long_context_llm, and strategic_llm IDs
|
||||
# Get language from configured LLM preferences
|
||||
# LLM preferences are now stored on the SearchSpace model
|
||||
from app.config import config as app_config
|
||||
|
||||
for llm_id in [
|
||||
user_preference.fast_llm_id,
|
||||
user_preference.long_context_llm_id,
|
||||
user_preference.strategic_llm_id,
|
||||
search_space.fast_llm_id,
|
||||
search_space.long_context_llm_id,
|
||||
search_space.strategic_llm_id,
|
||||
]:
|
||||
if llm_id is not None:
|
||||
# Check if it's a global config (negative ID)
|
||||
|
|
@ -161,8 +161,18 @@ async def create_chat(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Create a new chat.
|
||||
Requires CHATS_CREATE permission.
|
||||
"""
|
||||
try:
|
||||
await check_ownership(session, SearchSpace, chat.search_space_id, user)
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
chat.search_space_id,
|
||||
Permission.CHATS_CREATE.value,
|
||||
"You don't have permission to create chats in this search space",
|
||||
)
|
||||
db_chat = Chat(**chat.model_dump())
|
||||
session.add(db_chat)
|
||||
await session.commit()
|
||||
|
|
@ -197,6 +207,10 @@ async def read_chats(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
List chats the user has access to.
|
||||
Requires CHATS_READ permission for the search space(s).
|
||||
"""
|
||||
# Validate pagination parameters
|
||||
if skip < 0:
|
||||
raise HTTPException(
|
||||
|
|
@ -212,9 +226,17 @@ async def read_chats(
|
|||
status_code=400, detail="search_space_id must be a positive integer"
|
||||
)
|
||||
try:
|
||||
# Select specific fields excluding messages
|
||||
query = (
|
||||
select(
|
||||
if search_space_id is not None:
|
||||
# Check permission for specific search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.CHATS_READ.value,
|
||||
"You don't have permission to read chats in this search space",
|
||||
)
|
||||
# Select specific fields excluding messages
|
||||
query = select(
|
||||
Chat.id,
|
||||
Chat.type,
|
||||
Chat.title,
|
||||
|
|
@ -222,17 +244,28 @@ async def read_chats(
|
|||
Chat.search_space_id,
|
||||
Chat.created_at,
|
||||
Chat.state_version,
|
||||
).filter(Chat.search_space_id == search_space_id)
|
||||
else:
|
||||
# Get chats from all search spaces user has membership in
|
||||
query = (
|
||||
select(
|
||||
Chat.id,
|
||||
Chat.type,
|
||||
Chat.title,
|
||||
Chat.initial_connectors,
|
||||
Chat.search_space_id,
|
||||
Chat.created_at,
|
||||
Chat.state_version,
|
||||
)
|
||||
.join(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
)
|
||||
.join(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
)
|
||||
|
||||
# Filter by search_space_id if provided
|
||||
if search_space_id is not None:
|
||||
query = query.filter(Chat.search_space_id == search_space_id)
|
||||
|
||||
result = await session.execute(query.offset(skip).limit(limit))
|
||||
return result.all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except OperationalError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Database operation failed. Please try again later."
|
||||
|
|
@ -249,19 +282,32 @@ async def read_chat(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific chat by ID.
|
||||
Requires CHATS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Chat)
|
||||
.join(SearchSpace)
|
||||
.filter(Chat.id == chat_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||
chat = result.scalars().first()
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Chat not found or you don't have permission to access it",
|
||||
detail="Chat not found",
|
||||
)
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
chat.search_space_id,
|
||||
Permission.CHATS_READ.value,
|
||||
"You don't have permission to read chats in this search space",
|
||||
)
|
||||
|
||||
return chat
|
||||
except HTTPException:
|
||||
raise
|
||||
except OperationalError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Database operation failed. Please try again later."
|
||||
|
|
@ -280,8 +326,26 @@ async def update_chat(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a chat.
|
||||
Requires CHATS_UPDATE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
db_chat = await read_chat(chat_id, session, user)
|
||||
result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||
db_chat = result.scalars().first()
|
||||
|
||||
if not db_chat:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_chat.search_space_id,
|
||||
Permission.CHATS_UPDATE.value,
|
||||
"You don't have permission to update chats in this search space",
|
||||
)
|
||||
|
||||
update_data = chat_update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
if key == "messages":
|
||||
|
|
@ -318,8 +382,26 @@ async def delete_chat(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a chat.
|
||||
Requires CHATS_DELETE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
db_chat = await read_chat(chat_id, session, user)
|
||||
result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||
db_chat = result.scalars().first()
|
||||
|
||||
if not db_chat:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_chat.search_space_id,
|
||||
Permission.CHATS_DELETE.value,
|
||||
"You don't have permission to delete chats in this search space",
|
||||
)
|
||||
|
||||
await session.delete(db_chat)
|
||||
await session.commit()
|
||||
return {"message": "Chat deleted successfully"}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ from app.db import (
|
|||
Chunk,
|
||||
Document,
|
||||
DocumentType,
|
||||
Permission,
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
|
|
@ -22,7 +24,7 @@ from app.schemas import (
|
|||
PaginatedResponse,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
try:
|
||||
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
||||
|
|
@ -44,9 +46,19 @@ async def create_documents(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Create new documents.
|
||||
Requires DOCUMENTS_CREATE permission.
|
||||
"""
|
||||
try:
|
||||
# Check if the user owns the search space
|
||||
await check_ownership(session, SearchSpace, request.search_space_id, user)
|
||||
# Check permission
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
request.search_space_id,
|
||||
Permission.DOCUMENTS_CREATE.value,
|
||||
"You don't have permission to create documents in this search space",
|
||||
)
|
||||
|
||||
if request.document_type == DocumentType.EXTENSION:
|
||||
from app.tasks.celery_tasks.document_tasks import (
|
||||
|
|
@ -93,8 +105,19 @@ async def create_documents_file_upload(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Upload files as documents.
|
||||
Requires DOCUMENTS_CREATE permission.
|
||||
"""
|
||||
try:
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
# Check permission
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.DOCUMENTS_CREATE.value,
|
||||
"You don't have permission to create documents in this search space",
|
||||
)
|
||||
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="No files provided")
|
||||
|
|
@ -151,7 +174,8 @@ async def read_documents(
|
|||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
List documents owned by the current user, with optional filtering and pagination.
|
||||
List documents the user has access to, with optional filtering and pagination.
|
||||
Requires DOCUMENTS_READ permission for the search space(s).
|
||||
|
||||
Args:
|
||||
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'.
|
||||
|
|
@ -167,40 +191,49 @@ async def read_documents(
|
|||
|
||||
Notes:
|
||||
- If both 'skip' and 'page' are provided, 'skip' is used.
|
||||
- Results are scoped to documents owned by the current user.
|
||||
- Results are scoped to documents in search spaces the user has membership in.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
|
||||
query = (
|
||||
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
|
||||
)
|
||||
|
||||
# Filter by search_space_id if provided
|
||||
# If specific search_space_id, check permission
|
||||
if search_space_id is not None:
|
||||
query = query.filter(Document.search_space_id == search_space_id)
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.DOCUMENTS_READ.value,
|
||||
"You don't have permission to read documents in this search space",
|
||||
)
|
||||
query = select(Document).filter(Document.search_space_id == search_space_id)
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(Document)
|
||||
.filter(Document.search_space_id == search_space_id)
|
||||
)
|
||||
else:
|
||||
# Get documents from all search spaces user has membership in
|
||||
query = (
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
)
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(Document)
|
||||
.join(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
)
|
||||
|
||||
# Filter by document_types if provided
|
||||
if document_types is not None and document_types.strip():
|
||||
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
||||
if type_list:
|
||||
query = query.filter(Document.document_type.in_(type_list))
|
||||
|
||||
# Get total count
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
)
|
||||
if search_space_id is not None:
|
||||
count_query = count_query.filter(
|
||||
Document.search_space_id == search_space_id
|
||||
)
|
||||
if document_types is not None and document_types.strip():
|
||||
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
||||
if type_list:
|
||||
count_query = count_query.filter(Document.document_type.in_(type_list))
|
||||
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
|
|
@ -235,6 +268,8 @@ async def read_documents(
|
|||
)
|
||||
|
||||
return PaginatedResponse(items=api_documents, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch documents: {e!s}"
|
||||
|
|
@ -254,6 +289,7 @@ async def search_documents(
|
|||
):
|
||||
"""
|
||||
Search documents by title substring, optionally filtered by search_space_id and document_types.
|
||||
Requires DOCUMENTS_READ permission for the search space(s).
|
||||
|
||||
Args:
|
||||
title: Case-insensitive substring to match against document titles. Required.
|
||||
|
|
@ -275,37 +311,48 @@ async def search_documents(
|
|||
try:
|
||||
from sqlalchemy import func
|
||||
|
||||
query = (
|
||||
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
|
||||
)
|
||||
# If specific search_space_id, check permission
|
||||
if search_space_id is not None:
|
||||
query = query.filter(Document.search_space_id == search_space_id)
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.DOCUMENTS_READ.value,
|
||||
"You don't have permission to read documents in this search space",
|
||||
)
|
||||
query = select(Document).filter(Document.search_space_id == search_space_id)
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(Document)
|
||||
.filter(Document.search_space_id == search_space_id)
|
||||
)
|
||||
else:
|
||||
# Get documents from all search spaces user has membership in
|
||||
query = (
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
)
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(Document)
|
||||
.join(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
)
|
||||
|
||||
# Only search by title (case-insensitive)
|
||||
query = query.filter(Document.title.ilike(f"%{title}%"))
|
||||
count_query = count_query.filter(Document.title.ilike(f"%{title}%"))
|
||||
|
||||
# Filter by document_types if provided
|
||||
if document_types is not None and document_types.strip():
|
||||
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
||||
if type_list:
|
||||
query = query.filter(Document.document_type.in_(type_list))
|
||||
|
||||
# Get total count
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
)
|
||||
if search_space_id is not None:
|
||||
count_query = count_query.filter(
|
||||
Document.search_space_id == search_space_id
|
||||
)
|
||||
count_query = count_query.filter(Document.title.ilike(f"%{title}%"))
|
||||
if document_types is not None and document_types.strip():
|
||||
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
||||
if type_list:
|
||||
count_query = count_query.filter(Document.document_type.in_(type_list))
|
||||
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
|
|
@ -340,6 +387,8 @@ async def search_documents(
|
|||
)
|
||||
|
||||
return PaginatedResponse(items=api_documents, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to search documents: {e!s}"
|
||||
|
|
@ -353,7 +402,8 @@ async def get_document_type_counts(
|
|||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get counts of documents by type for the current user.
|
||||
Get counts of documents by type for search spaces the user has access to.
|
||||
Requires DOCUMENTS_READ permission for the search space(s).
|
||||
|
||||
Args:
|
||||
search_space_id: If provided, restrict counts to a specific search space.
|
||||
|
|
@ -366,20 +416,36 @@ async def get_document_type_counts(
|
|||
try:
|
||||
from sqlalchemy import func
|
||||
|
||||
query = (
|
||||
select(Document.document_type, func.count(Document.id))
|
||||
.join(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.group_by(Document.document_type)
|
||||
)
|
||||
|
||||
if search_space_id is not None:
|
||||
query = query.filter(Document.search_space_id == search_space_id)
|
||||
# Check permission for specific search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.DOCUMENTS_READ.value,
|
||||
"You don't have permission to read documents in this search space",
|
||||
)
|
||||
query = (
|
||||
select(Document.document_type, func.count(Document.id))
|
||||
.filter(Document.search_space_id == search_space_id)
|
||||
.group_by(Document.document_type)
|
||||
)
|
||||
else:
|
||||
# Get counts from all search spaces user has membership in
|
||||
query = (
|
||||
select(Document.document_type, func.count(Document.id))
|
||||
.join(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
.group_by(Document.document_type)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
type_counts = dict(result.all())
|
||||
|
||||
return type_counts
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch document type counts: {e!s}"
|
||||
|
|
@ -394,6 +460,7 @@ async def get_document_by_chunk_id(
|
|||
):
|
||||
"""
|
||||
Retrieves a document based on a chunk ID, including all its chunks ordered by creation time.
|
||||
Requires DOCUMENTS_READ permission for the search space.
|
||||
The document's embedding and chunk embeddings are excluded from the response.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -406,21 +473,29 @@ async def get_document_by_chunk_id(
|
|||
status_code=404, detail=f"Chunk with id {chunk_id} not found"
|
||||
)
|
||||
|
||||
# Get the associated document and verify ownership
|
||||
# Get the associated document
|
||||
document_result = await session.execute(
|
||||
select(Document)
|
||||
.options(selectinload(Document.chunks))
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id == chunk.document_id, SearchSpace.user_id == user.id)
|
||||
.filter(Document.id == chunk.document_id)
|
||||
)
|
||||
document = document_result.scalars().first()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Document not found or you don't have access to it",
|
||||
detail="Document not found",
|
||||
)
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
document.search_space_id,
|
||||
Permission.DOCUMENTS_READ.value,
|
||||
"You don't have permission to read documents in this search space",
|
||||
)
|
||||
|
||||
# Sort chunks by creation time
|
||||
sorted_chunks = sorted(document.chunks, key=lambda x: x.created_at)
|
||||
|
||||
|
|
@ -449,11 +524,13 @@ async def read_document(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific document by ID.
|
||||
Requires DOCUMENTS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
||||
select(Document).filter(Document.id == document_id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -462,6 +539,15 @@ async def read_document(
|
|||
status_code=404, detail=f"Document with id {document_id} not found"
|
||||
)
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
document.search_space_id,
|
||||
Permission.DOCUMENTS_READ.value,
|
||||
"You don't have permission to read documents in this search space",
|
||||
)
|
||||
|
||||
# Convert database object to API-friendly format
|
||||
return DocumentRead(
|
||||
id=document.id,
|
||||
|
|
@ -472,6 +558,8 @@ async def read_document(
|
|||
created_at=document.created_at,
|
||||
search_space_id=document.search_space_id,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch document: {e!s}"
|
||||
|
|
@ -485,12 +573,13 @@ async def update_document(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a document.
|
||||
Requires DOCUMENTS_UPDATE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Query the document directly instead of using read_document function
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
||||
select(Document).filter(Document.id == document_id)
|
||||
)
|
||||
db_document = result.scalars().first()
|
||||
|
||||
|
|
@ -499,6 +588,15 @@ async def update_document(
|
|||
status_code=404, detail=f"Document with id {document_id} not found"
|
||||
)
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_document.search_space_id,
|
||||
Permission.DOCUMENTS_UPDATE.value,
|
||||
"You don't have permission to update documents in this search space",
|
||||
)
|
||||
|
||||
update_data = document_update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_document, key, value)
|
||||
|
|
@ -530,12 +628,13 @@ async def delete_document(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a document.
|
||||
Requires DOCUMENTS_DELETE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Query the document directly instead of using read_document function
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
||||
select(Document).filter(Document.id == document_id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -544,6 +643,15 @@ async def delete_document(
|
|||
status_code=404, detail=f"Document with id {document_id} not found"
|
||||
)
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
document.search_space_id,
|
||||
Permission.DOCUMENTS_DELETE.value,
|
||||
"You don't have permission to delete documents in this search space",
|
||||
)
|
||||
|
||||
await session.delete(document)
|
||||
await session.commit()
|
||||
return {"message": "Document deleted successfully"}
|
||||
|
|
|
|||
|
|
@ -8,67 +8,22 @@ from sqlalchemy.future import select
|
|||
from app.config import config
|
||||
from app.db import (
|
||||
LLMConfig,
|
||||
Permission,
|
||||
SearchSpace,
|
||||
User,
|
||||
UserSearchSpacePreference,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas import LLMConfigCreate, LLMConfigRead, LLMConfigUpdate
|
||||
from app.services.llm_service import validate_llm_config
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Helper function to check search space access
|
||||
async def check_search_space_access(
|
||||
session: AsyncSession, search_space_id: int, user: User
|
||||
) -> SearchSpace:
|
||||
"""Verify that the user has access to the search space"""
|
||||
result = await session.execute(
|
||||
select(SearchSpace).filter(
|
||||
SearchSpace.id == search_space_id, SearchSpace.user_id == user.id
|
||||
)
|
||||
)
|
||||
search_space = result.scalars().first()
|
||||
if not search_space:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Search space not found or you don't have permission to access it",
|
||||
)
|
||||
return search_space
|
||||
|
||||
|
||||
# Helper function to get or create user search space preference
|
||||
async def get_or_create_user_preference(
|
||||
session: AsyncSession, user_id, search_space_id: int
|
||||
) -> UserSearchSpacePreference:
|
||||
"""Get or create user preference for a search space"""
|
||||
result = await session.execute(
|
||||
select(UserSearchSpacePreference).filter(
|
||||
UserSearchSpacePreference.user_id == user_id,
|
||||
UserSearchSpacePreference.search_space_id == search_space_id,
|
||||
)
|
||||
# Removed selectinload options since relationships no longer exist
|
||||
)
|
||||
preference = result.scalars().first()
|
||||
|
||||
if not preference:
|
||||
# Create new preference entry
|
||||
preference = UserSearchSpacePreference(
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
)
|
||||
session.add(preference)
|
||||
await session.commit()
|
||||
await session.refresh(preference)
|
||||
|
||||
return preference
|
||||
|
||||
|
||||
class LLMPreferencesUpdate(BaseModel):
|
||||
"""Schema for updating user LLM preferences"""
|
||||
"""Schema for updating search space LLM preferences"""
|
||||
|
||||
long_context_llm_id: int | None = None
|
||||
fast_llm_id: int | None = None
|
||||
|
|
@ -76,7 +31,7 @@ class LLMPreferencesUpdate(BaseModel):
|
|||
|
||||
|
||||
class LLMPreferencesRead(BaseModel):
|
||||
"""Schema for reading user LLM preferences"""
|
||||
"""Schema for reading search space LLM preferences"""
|
||||
|
||||
long_context_llm_id: int | None = None
|
||||
fast_llm_id: int | None = None
|
||||
|
|
@ -144,10 +99,19 @@ async def create_llm_config(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Create a new LLM configuration for a search space"""
|
||||
"""
|
||||
Create a new LLM configuration for a search space.
|
||||
Requires LLM_CONFIGS_CREATE permission.
|
||||
"""
|
||||
try:
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, llm_config.search_space_id, user)
|
||||
# Verify user has permission to create LLM configs
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
llm_config.search_space_id,
|
||||
Permission.LLM_CONFIGS_CREATE.value,
|
||||
"You don't have permission to create LLM configurations in this search space",
|
||||
)
|
||||
|
||||
# Validate the LLM configuration by making a test API call
|
||||
is_valid, error_message = await validate_llm_config(
|
||||
|
|
@ -187,10 +151,19 @@ async def read_llm_configs(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Get all LLM configurations for a search space"""
|
||||
"""
|
||||
Get all LLM configurations for a search space.
|
||||
Requires LLM_CONFIGS_READ permission.
|
||||
"""
|
||||
try:
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, search_space_id, user)
|
||||
# Verify user has permission to read LLM configs
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
"You don't have permission to view LLM configurations in this search space",
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(LLMConfig)
|
||||
|
|
@ -213,7 +186,10 @@ async def read_llm_config(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Get a specific LLM configuration by ID"""
|
||||
"""
|
||||
Get a specific LLM configuration by ID.
|
||||
Requires LLM_CONFIGS_READ permission.
|
||||
"""
|
||||
try:
|
||||
# Get the LLM config
|
||||
result = await session.execute(
|
||||
|
|
@ -224,8 +200,14 @@ async def read_llm_config(
|
|||
if not llm_config:
|
||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, llm_config.search_space_id, user)
|
||||
# Verify user has permission to read LLM configs
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
llm_config.search_space_id,
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
"You don't have permission to view LLM configurations in this search space",
|
||||
)
|
||||
|
||||
return llm_config
|
||||
except HTTPException:
|
||||
|
|
@ -243,7 +225,10 @@ async def update_llm_config(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Update an existing LLM configuration"""
|
||||
"""
|
||||
Update an existing LLM configuration.
|
||||
Requires LLM_CONFIGS_UPDATE permission.
|
||||
"""
|
||||
try:
|
||||
# Get the LLM config
|
||||
result = await session.execute(
|
||||
|
|
@ -254,8 +239,14 @@ async def update_llm_config(
|
|||
if not db_llm_config:
|
||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, db_llm_config.search_space_id, user)
|
||||
# Verify user has permission to update LLM configs
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_llm_config.search_space_id,
|
||||
Permission.LLM_CONFIGS_UPDATE.value,
|
||||
"You don't have permission to update LLM configurations in this search space",
|
||||
)
|
||||
|
||||
update_data = llm_config_update.model_dump(exclude_unset=True)
|
||||
|
||||
|
|
@ -311,7 +302,10 @@ async def delete_llm_config(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Delete an LLM configuration"""
|
||||
"""
|
||||
Delete an LLM configuration.
|
||||
Requires LLM_CONFIGS_DELETE permission.
|
||||
"""
|
||||
try:
|
||||
# Get the LLM config
|
||||
result = await session.execute(
|
||||
|
|
@ -322,8 +316,14 @@ async def delete_llm_config(
|
|||
if not db_llm_config:
|
||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, db_llm_config.search_space_id, user)
|
||||
# Verify user has permission to delete LLM configs
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_llm_config.search_space_id,
|
||||
Permission.LLM_CONFIGS_DELETE.value,
|
||||
"You don't have permission to delete LLM configurations in this search space",
|
||||
)
|
||||
|
||||
await session.delete(db_llm_config)
|
||||
await session.commit()
|
||||
|
|
@ -337,28 +337,42 @@ async def delete_llm_config(
|
|||
) from e
|
||||
|
||||
|
||||
# User LLM Preferences endpoints
|
||||
# Search Space LLM Preferences endpoints
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search-spaces/{search_space_id}/llm-preferences",
|
||||
response_model=LLMPreferencesRead,
|
||||
)
|
||||
async def get_user_llm_preferences(
|
||||
async def get_llm_preferences(
|
||||
search_space_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Get the current user's LLM preferences for a specific search space"""
|
||||
"""
|
||||
Get the LLM preferences for a specific search space.
|
||||
LLM preferences are shared by all members of the search space.
|
||||
Requires LLM_CONFIGS_READ permission.
|
||||
"""
|
||||
try:
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, search_space_id, user)
|
||||
|
||||
# Get or create user preference for this search space
|
||||
preference = await get_or_create_user_preference(
|
||||
session, user.id, search_space_id
|
||||
# Verify user has permission to read LLM configs
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
"You don't have permission to view LLM preferences in this search space",
|
||||
)
|
||||
|
||||
# Get the search space
|
||||
result = await session.execute(
|
||||
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||
)
|
||||
search_space = result.scalars().first()
|
||||
|
||||
if not search_space:
|
||||
raise HTTPException(status_code=404, detail="Search space not found")
|
||||
|
||||
# Helper function to get config (global or custom)
|
||||
async def get_config_for_id(config_id):
|
||||
if config_id is None:
|
||||
|
|
@ -391,14 +405,14 @@ async def get_user_llm_preferences(
|
|||
return result.scalars().first()
|
||||
|
||||
# Get the configs (from DB for custom, or constructed for global)
|
||||
long_context_llm = await get_config_for_id(preference.long_context_llm_id)
|
||||
fast_llm = await get_config_for_id(preference.fast_llm_id)
|
||||
strategic_llm = await get_config_for_id(preference.strategic_llm_id)
|
||||
long_context_llm = await get_config_for_id(search_space.long_context_llm_id)
|
||||
fast_llm = await get_config_for_id(search_space.fast_llm_id)
|
||||
strategic_llm = await get_config_for_id(search_space.strategic_llm_id)
|
||||
|
||||
return {
|
||||
"long_context_llm_id": preference.long_context_llm_id,
|
||||
"fast_llm_id": preference.fast_llm_id,
|
||||
"strategic_llm_id": preference.strategic_llm_id,
|
||||
"long_context_llm_id": search_space.long_context_llm_id,
|
||||
"fast_llm_id": search_space.fast_llm_id,
|
||||
"strategic_llm_id": search_space.strategic_llm_id,
|
||||
"long_context_llm": long_context_llm,
|
||||
"fast_llm": fast_llm,
|
||||
"strategic_llm": strategic_llm,
|
||||
|
|
@ -415,22 +429,37 @@ async def get_user_llm_preferences(
|
|||
"/search-spaces/{search_space_id}/llm-preferences",
|
||||
response_model=LLMPreferencesRead,
|
||||
)
|
||||
async def update_user_llm_preferences(
|
||||
async def update_llm_preferences(
|
||||
search_space_id: int,
|
||||
preferences: LLMPreferencesUpdate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Update the current user's LLM preferences for a specific search space"""
|
||||
"""
|
||||
Update the LLM preferences for a specific search space.
|
||||
LLM preferences are shared by all members of the search space.
|
||||
Requires SETTINGS_UPDATE permission (only users with settings access can change).
|
||||
"""
|
||||
try:
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, search_space_id, user)
|
||||
|
||||
# Get or create user preference for this search space
|
||||
preference = await get_or_create_user_preference(
|
||||
session, user.id, search_space_id
|
||||
# Verify user has permission to update settings (not just LLM configs)
|
||||
# This ensures only users with settings access can change shared LLM preferences
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.SETTINGS_UPDATE.value,
|
||||
"You don't have permission to update LLM preferences in this search space",
|
||||
)
|
||||
|
||||
# Get the search space
|
||||
result = await session.execute(
|
||||
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||
)
|
||||
search_space = result.scalars().first()
|
||||
|
||||
if not search_space:
|
||||
raise HTTPException(status_code=404, detail="Search space not found")
|
||||
|
||||
# Validate that all provided LLM config IDs belong to the search space
|
||||
update_data = preferences.model_dump(exclude_unset=True)
|
||||
|
||||
|
|
@ -485,18 +514,13 @@ async def update_user_llm_preferences(
|
|||
f"Multiple languages detected in LLM selection for search_space {search_space_id}: {languages}. "
|
||||
"This may affect response quality."
|
||||
)
|
||||
# Don't raise an exception - allow users to proceed
|
||||
# raise HTTPException(
|
||||
# status_code=400,
|
||||
# detail="All selected LLM configurations must have the same language setting",
|
||||
# )
|
||||
|
||||
# Update user preferences
|
||||
# Update search space LLM preferences
|
||||
for key, value in update_data.items():
|
||||
setattr(preference, key, value)
|
||||
setattr(search_space, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(preference)
|
||||
await session.refresh(search_space)
|
||||
|
||||
# Helper function to get config (global or custom)
|
||||
async def get_config_for_id(config_id):
|
||||
|
|
@ -530,15 +554,15 @@ async def update_user_llm_preferences(
|
|||
return result.scalars().first()
|
||||
|
||||
# Get the configs (from DB for custom, or constructed for global)
|
||||
long_context_llm = await get_config_for_id(preference.long_context_llm_id)
|
||||
fast_llm = await get_config_for_id(preference.fast_llm_id)
|
||||
strategic_llm = await get_config_for_id(preference.strategic_llm_id)
|
||||
long_context_llm = await get_config_for_id(search_space.long_context_llm_id)
|
||||
fast_llm = await get_config_for_id(search_space.fast_llm_id)
|
||||
strategic_llm = await get_config_for_id(search_space.strategic_llm_id)
|
||||
|
||||
# Return updated preferences
|
||||
return {
|
||||
"long_context_llm_id": preference.long_context_llm_id,
|
||||
"fast_llm_id": preference.fast_llm_id,
|
||||
"strategic_llm_id": preference.strategic_llm_id,
|
||||
"long_context_llm_id": search_space.long_context_llm_id,
|
||||
"fast_llm_id": search_space.fast_llm_id,
|
||||
"strategic_llm_id": search_space.strategic_llm_id,
|
||||
"long_context_llm": long_context_llm,
|
||||
"fast_llm": fast_llm,
|
||||
"strategic_llm": strategic_llm,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,19 @@ from sqlalchemy import and_, desc
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.db import Log, LogLevel, LogStatus, SearchSpace, User, get_async_session
|
||||
from app.db import (
|
||||
Log,
|
||||
LogLevel,
|
||||
LogStatus,
|
||||
Permission,
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas import LogCreate, LogRead, LogUpdate
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -19,10 +28,19 @@ async def create_log(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Create a new log entry."""
|
||||
"""
|
||||
Create a new log entry.
|
||||
Note: This is typically called internally. Requires LOGS_READ permission (since logs are usually system-generated).
|
||||
"""
|
||||
try:
|
||||
# Check if the user owns the search space
|
||||
await check_ownership(session, SearchSpace, log.search_space_id, user)
|
||||
# Check if the user has access to the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
log.search_space_id,
|
||||
Permission.LOGS_READ.value,
|
||||
"You don't have permission to access logs in this search space",
|
||||
)
|
||||
|
||||
db_log = Log(**log.model_dump())
|
||||
session.add(db_log)
|
||||
|
|
@ -51,22 +69,38 @@ async def read_logs(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Get logs with optional filtering."""
|
||||
"""
|
||||
Get logs with optional filtering.
|
||||
Requires LOGS_READ permission for the search space(s).
|
||||
"""
|
||||
try:
|
||||
# Build base query - only logs from user's search spaces
|
||||
query = (
|
||||
select(Log)
|
||||
.join(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.order_by(desc(Log.created_at)) # Most recent first
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
filters = []
|
||||
|
||||
if search_space_id is not None:
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
filters.append(Log.search_space_id == search_space_id)
|
||||
# Check permission for specific search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.LOGS_READ.value,
|
||||
"You don't have permission to read logs in this search space",
|
||||
)
|
||||
# Build query for specific search space
|
||||
query = (
|
||||
select(Log)
|
||||
.filter(Log.search_space_id == search_space_id)
|
||||
.order_by(desc(Log.created_at))
|
||||
)
|
||||
else:
|
||||
# Build base query - logs from search spaces user has membership in
|
||||
query = (
|
||||
select(Log)
|
||||
.join(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
.order_by(desc(Log.created_at))
|
||||
)
|
||||
|
||||
if level is not None:
|
||||
filters.append(Log.level == level)
|
||||
|
|
@ -104,19 +138,26 @@ async def read_log(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Get a specific log by ID."""
|
||||
"""
|
||||
Get a specific log by ID.
|
||||
Requires LOGS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Get log and verify user owns the search space
|
||||
result = await session.execute(
|
||||
select(Log)
|
||||
.join(SearchSpace)
|
||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||
log = result.scalars().first()
|
||||
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="Log not found")
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
log.search_space_id,
|
||||
Permission.LOGS_READ.value,
|
||||
"You don't have permission to read logs in this search space",
|
||||
)
|
||||
|
||||
return log
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -133,19 +174,26 @@ async def update_log(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Update a log entry."""
|
||||
"""
|
||||
Update a log entry.
|
||||
Requires LOGS_READ permission (logs are typically updated by system).
|
||||
"""
|
||||
try:
|
||||
# Get log and verify user owns the search space
|
||||
result = await session.execute(
|
||||
select(Log)
|
||||
.join(SearchSpace)
|
||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||
db_log = result.scalars().first()
|
||||
|
||||
if not db_log:
|
||||
raise HTTPException(status_code=404, detail="Log not found")
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_log.search_space_id,
|
||||
Permission.LOGS_READ.value,
|
||||
"You don't have permission to access logs in this search space",
|
||||
)
|
||||
|
||||
# Update only provided fields
|
||||
update_data = log_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
|
|
@ -169,19 +217,26 @@ async def delete_log(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Delete a log entry."""
|
||||
"""
|
||||
Delete a log entry.
|
||||
Requires LOGS_DELETE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Get log and verify user owns the search space
|
||||
result = await session.execute(
|
||||
select(Log)
|
||||
.join(SearchSpace)
|
||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||
db_log = result.scalars().first()
|
||||
|
||||
if not db_log:
|
||||
raise HTTPException(status_code=404, detail="Log not found")
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_log.search_space_id,
|
||||
Permission.LOGS_DELETE.value,
|
||||
"You don't have permission to delete logs in this search space",
|
||||
)
|
||||
|
||||
await session.delete(db_log)
|
||||
await session.commit()
|
||||
return {"message": "Log deleted successfully"}
|
||||
|
|
@ -201,10 +256,19 @@ async def get_logs_summary(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Get a summary of logs for a search space in the last X hours."""
|
||||
"""
|
||||
Get a summary of logs for a search space in the last X hours.
|
||||
Requires LOGS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Check ownership
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
# Check permission
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.LOGS_READ.value,
|
||||
"You don't have permission to read logs in this search space",
|
||||
)
|
||||
|
||||
# Calculate time window
|
||||
since = datetime.utcnow().replace(microsecond=0) - timedelta(hours=hours)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,15 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.db import Chat, Podcast, SearchSpace, User, get_async_session
|
||||
from app.db import (
|
||||
Chat,
|
||||
Permission,
|
||||
Podcast,
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas import (
|
||||
PodcastCreate,
|
||||
PodcastGenerateRequest,
|
||||
|
|
@ -16,7 +24,7 @@ from app.schemas import (
|
|||
)
|
||||
from app.tasks.podcast_tasks import generate_chat_podcast
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -27,8 +35,18 @@ async def create_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Create a new podcast.
|
||||
Requires PODCASTS_CREATE permission.
|
||||
"""
|
||||
try:
|
||||
await check_ownership(session, SearchSpace, podcast.search_space_id, user)
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
podcast.search_space_id,
|
||||
Permission.PODCASTS_CREATE.value,
|
||||
"You don't have permission to create podcasts in this search space",
|
||||
)
|
||||
db_podcast = Podcast(**podcast.model_dump())
|
||||
session.add(db_podcast)
|
||||
await session.commit()
|
||||
|
|
@ -58,20 +76,45 @@ async def create_podcast(
|
|||
async def read_podcasts(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search_space_id: int | None = None,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
List podcasts the user has access to.
|
||||
Requires PODCASTS_READ permission for the search space(s).
|
||||
"""
|
||||
if skip < 0 or limit < 1:
|
||||
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
if search_space_id is not None:
|
||||
# Check permission for specific search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.PODCASTS_READ.value,
|
||||
"You don't have permission to read podcasts in this search space",
|
||||
)
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.filter(Podcast.search_space_id == search_space_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
else:
|
||||
# Get podcasts from all search spaces user has membership in
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Database error occurred while fetching podcasts"
|
||||
|
|
@ -84,18 +127,29 @@ async def read_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific podcast by ID.
|
||||
Requires PODCASTS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||
podcast = result.scalars().first()
|
||||
|
||||
if not podcast:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Podcast not found or you don't have permission to access it",
|
||||
detail="Podcast not found",
|
||||
)
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
podcast.search_space_id,
|
||||
Permission.PODCASTS_READ.value,
|
||||
"You don't have permission to read podcasts in this search space",
|
||||
)
|
||||
|
||||
return podcast
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
|
|
@ -112,8 +166,26 @@ async def update_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a podcast.
|
||||
Requires PODCASTS_UPDATE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
db_podcast = await read_podcast(podcast_id, session, user)
|
||||
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||
db_podcast = result.scalars().first()
|
||||
|
||||
if not db_podcast:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_podcast.search_space_id,
|
||||
Permission.PODCASTS_UPDATE.value,
|
||||
"You don't have permission to update podcasts in this search space",
|
||||
)
|
||||
|
||||
update_data = podcast_update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_podcast, key, value)
|
||||
|
|
@ -140,8 +212,26 @@ async def delete_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a podcast.
|
||||
Requires PODCASTS_DELETE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
db_podcast = await read_podcast(podcast_id, session, user)
|
||||
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||
db_podcast = result.scalars().first()
|
||||
|
||||
if not db_podcast:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_podcast.search_space_id,
|
||||
Permission.PODCASTS_DELETE.value,
|
||||
"You don't have permission to delete podcasts in this search space",
|
||||
)
|
||||
|
||||
await session.delete(db_podcast)
|
||||
await session.commit()
|
||||
return {"message": "Podcast deleted successfully"}
|
||||
|
|
@ -181,9 +271,19 @@ async def generate_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Generate a podcast from a chat or document.
|
||||
Requires PODCASTS_CREATE permission.
|
||||
"""
|
||||
try:
|
||||
# Check if the user owns the search space
|
||||
await check_ownership(session, SearchSpace, request.search_space_id, user)
|
||||
# Check if the user has permission to create podcasts
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
request.search_space_id,
|
||||
Permission.PODCASTS_CREATE.value,
|
||||
"You don't have permission to create podcasts in this search space",
|
||||
)
|
||||
|
||||
if request.type == "CHAT":
|
||||
# Verify that all chat IDs belong to this user and search space
|
||||
|
|
@ -251,22 +351,29 @@ async def stream_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Stream a podcast audio file."""
|
||||
"""
|
||||
Stream a podcast audio file.
|
||||
Requires PODCASTS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Get the podcast and check if user has access
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||
podcast = result.scalars().first()
|
||||
|
||||
if not podcast:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Podcast not found or you don't have permission to access it",
|
||||
detail="Podcast not found",
|
||||
)
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
podcast.search_space_id,
|
||||
Permission.PODCASTS_READ.value,
|
||||
"You don't have permission to access podcasts in this search space",
|
||||
)
|
||||
|
||||
# Get the file path
|
||||
file_path = podcast.file_location
|
||||
|
||||
|
|
@ -303,12 +410,30 @@ async def get_podcast_by_chat_id(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a podcast by its associated chat ID.
|
||||
Requires PODCASTS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Get the podcast and check if user has access
|
||||
# First get the chat to find its search space
|
||||
chat_result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||
chat = chat_result.scalars().first()
|
||||
|
||||
if not chat:
|
||||
return None
|
||||
|
||||
# Check permission for the search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
chat.search_space_id,
|
||||
Permission.PODCASTS_READ.value,
|
||||
"You don't have permission to read podcasts in this search space",
|
||||
)
|
||||
|
||||
# Get the podcast
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.filter(Podcast.chat_id == chat_id, SearchSpace.user_id == user.id)
|
||||
select(Podcast).filter(Podcast.chat_id == chat_id)
|
||||
)
|
||||
podcast = result.scalars().first()
|
||||
|
||||
|
|
|
|||
1084
surfsense_backend/app/routes/rbac_routes.py
Normal file
1084
surfsense_backend/app/routes/rbac_routes.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,9 +22,9 @@ from sqlalchemy.future import select
|
|||
|
||||
from app.connectors.github_connector import GitHubConnector
|
||||
from app.db import (
|
||||
Permission,
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
SearchSpace,
|
||||
User,
|
||||
async_session_maker,
|
||||
get_async_session,
|
||||
|
|
@ -52,12 +52,12 @@ from app.tasks.connector_indexers import (
|
|||
index_slack_messages,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.periodic_scheduler import (
|
||||
create_periodic_schedule,
|
||||
delete_periodic_schedule,
|
||||
update_periodic_schedule,
|
||||
)
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -108,19 +108,25 @@ async def create_search_source_connector(
|
|||
):
|
||||
"""
|
||||
Create a new search source connector.
|
||||
Requires CONNECTORS_CREATE permission.
|
||||
|
||||
Each search space can have only one connector of each type per user (based on search_space_id, user_id, and connector_type).
|
||||
Each search space can have only one connector of each type (based on search_space_id and connector_type).
|
||||
The config must contain the appropriate keys for the connector type.
|
||||
"""
|
||||
try:
|
||||
# Check if the search space belongs to the user
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
# Check if user has permission to create connectors
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.CONNECTORS_CREATE.value,
|
||||
"You don't have permission to create connectors in this search space",
|
||||
)
|
||||
|
||||
# Check if a connector with the same type already exists for this search space and user
|
||||
# Check if a connector with the same type already exists for this search space
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.search_space_id == search_space_id,
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type == connector.connector_type,
|
||||
)
|
||||
)
|
||||
|
|
@ -128,7 +134,7 @@ async def create_search_source_connector(
|
|||
if existing_connector:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"A connector with type {connector.connector_type} already exists in this search space. Each search space can have only one connector of each type per user.",
|
||||
detail=f"A connector with type {connector.connector_type} already exists in this search space.",
|
||||
)
|
||||
|
||||
# Prepare connector data
|
||||
|
|
@ -198,22 +204,34 @@ async def read_search_source_connectors(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""List all search source connectors for the current user, optionally filtered by search space."""
|
||||
"""
|
||||
List all search source connectors for a search space.
|
||||
Requires CONNECTORS_READ permission.
|
||||
"""
|
||||
try:
|
||||
query = select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.user_id == user.id
|
||||
if search_space_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="search_space_id is required",
|
||||
)
|
||||
|
||||
# Check if user has permission to read connectors
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.CONNECTORS_READ.value,
|
||||
"You don't have permission to view connectors in this search space",
|
||||
)
|
||||
|
||||
# Filter by search_space_id if provided
|
||||
if search_space_id is not None:
|
||||
# Verify the search space belongs to the user
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
query = query.filter(
|
||||
SearchSourceConnector.search_space_id == search_space_id
|
||||
)
|
||||
query = select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.search_space_id == search_space_id
|
||||
)
|
||||
|
||||
result = await session.execute(query.offset(skip).limit(limit))
|
||||
return result.scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
|
|
@ -229,9 +247,32 @@ async def read_search_source_connector(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Get a specific search source connector by ID."""
|
||||
"""
|
||||
Get a specific search source connector by ID.
|
||||
Requires CONNECTORS_READ permission.
|
||||
"""
|
||||
try:
|
||||
return await check_ownership(session, SearchSourceConnector, connector_id, user)
|
||||
# Get the connector first
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id
|
||||
)
|
||||
)
|
||||
connector = result.scalars().first()
|
||||
|
||||
if not connector:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
|
||||
# Check permission
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
connector.search_space_id,
|
||||
Permission.CONNECTORS_READ.value,
|
||||
"You don't have permission to view this connector",
|
||||
)
|
||||
|
||||
return connector
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -251,10 +292,25 @@ async def update_search_source_connector(
|
|||
):
|
||||
"""
|
||||
Update a search source connector.
|
||||
Requires CONNECTORS_UPDATE permission.
|
||||
Handles partial updates, including merging changes into the 'config' field.
|
||||
"""
|
||||
db_connector = await check_ownership(
|
||||
session, SearchSourceConnector, connector_id, user
|
||||
# Get the connector first
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(SearchSourceConnector.id == connector_id)
|
||||
)
|
||||
db_connector = result.scalars().first()
|
||||
|
||||
if not db_connector:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
|
||||
# Check permission
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_connector.search_space_id,
|
||||
Permission.CONNECTORS_UPDATE.value,
|
||||
"You don't have permission to update this connector",
|
||||
)
|
||||
|
||||
# Convert the sparse update data (only fields present in request) to a dict
|
||||
|
|
@ -349,20 +405,19 @@ async def update_search_source_connector(
|
|||
for key, value in update_data.items():
|
||||
# Prevent changing connector_type if it causes a duplicate (check moved here)
|
||||
if key == "connector_type" and value != db_connector.connector_type:
|
||||
result = await session.execute(
|
||||
check_result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.search_space_id
|
||||
== db_connector.search_space_id,
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type == value,
|
||||
SearchSourceConnector.id != connector_id,
|
||||
)
|
||||
)
|
||||
existing_connector = result.scalars().first()
|
||||
existing_connector = check_result.scalars().first()
|
||||
if existing_connector:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"A connector with type {value} already exists in this search space. Each search space can have only one connector of each type per user.",
|
||||
detail=f"A connector with type {value} already exists in this search space.",
|
||||
)
|
||||
|
||||
setattr(db_connector, key, value)
|
||||
|
|
@ -425,10 +480,29 @@ async def delete_search_source_connector(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Delete a search source connector."""
|
||||
"""
|
||||
Delete a search source connector.
|
||||
Requires CONNECTORS_DELETE permission.
|
||||
"""
|
||||
try:
|
||||
db_connector = await check_ownership(
|
||||
session, SearchSourceConnector, connector_id, user
|
||||
# Get the connector first
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id
|
||||
)
|
||||
)
|
||||
db_connector = result.scalars().first()
|
||||
|
||||
if not db_connector:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
|
||||
# Check permission
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
db_connector.search_space_id,
|
||||
Permission.CONNECTORS_DELETE.value,
|
||||
"You don't have permission to delete this connector",
|
||||
)
|
||||
|
||||
# Delete any periodic schedule associated with this connector
|
||||
|
|
@ -473,6 +547,7 @@ async def index_connector_content(
|
|||
):
|
||||
"""
|
||||
Index content from a connector to a search space.
|
||||
Requires CONNECTORS_UPDATE permission (to trigger indexing).
|
||||
|
||||
Currently supports:
|
||||
- SLACK_CONNECTOR: Indexes messages from all accessible Slack channels
|
||||
|
|
@ -488,20 +563,29 @@ async def index_connector_content(
|
|||
Args:
|
||||
connector_id: ID of the connector to use
|
||||
search_space_id: ID of the search space to store indexed content
|
||||
background_tasks: FastAPI background tasks
|
||||
|
||||
Returns:
|
||||
Dictionary with indexing status
|
||||
"""
|
||||
try:
|
||||
# Check if the connector belongs to the user
|
||||
connector = await check_ownership(
|
||||
session, SearchSourceConnector, connector_id, user
|
||||
# Get the connector first
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id
|
||||
)
|
||||
)
|
||||
connector = result.scalars().first()
|
||||
|
||||
# Check if the search space belongs to the user
|
||||
_search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
if not connector:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
|
||||
# Check if user has permission to update connectors (indexing is an update operation)
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.CONNECTORS_UPDATE.value,
|
||||
"You don't have permission to index content in this search space",
|
||||
)
|
||||
|
||||
# Handle different connector types
|
||||
|
|
|
|||
|
|
@ -1,18 +1,77 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.db import SearchSpace, User, get_async_session
|
||||
from app.schemas import SearchSpaceCreate, SearchSpaceRead, SearchSpaceUpdate
|
||||
from app.db import (
|
||||
Permission,
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
SearchSpaceRole,
|
||||
User,
|
||||
get_async_session,
|
||||
get_default_roles_config,
|
||||
)
|
||||
from app.schemas import (
|
||||
SearchSpaceCreate,
|
||||
SearchSpaceRead,
|
||||
SearchSpaceUpdate,
|
||||
SearchSpaceWithStats,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission, check_search_space_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def create_default_roles_and_membership(
|
||||
session: AsyncSession,
|
||||
search_space_id: int,
|
||||
owner_user_id,
|
||||
) -> None:
|
||||
"""
|
||||
Create default system roles for a search space and add the owner as a member.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
search_space_id: The ID of the newly created search space
|
||||
owner_user_id: The UUID of the user who created the search space
|
||||
"""
|
||||
# Create default roles
|
||||
default_roles = get_default_roles_config()
|
||||
owner_role_id = None
|
||||
|
||||
for role_config in default_roles:
|
||||
db_role = SearchSpaceRole(
|
||||
name=role_config["name"],
|
||||
description=role_config["description"],
|
||||
permissions=role_config["permissions"],
|
||||
is_default=role_config["is_default"],
|
||||
is_system_role=role_config["is_system_role"],
|
||||
search_space_id=search_space_id,
|
||||
)
|
||||
session.add(db_role)
|
||||
await session.flush() # Get the ID
|
||||
|
||||
if role_config["name"] == "Owner":
|
||||
owner_role_id = db_role.id
|
||||
|
||||
# Create owner membership
|
||||
owner_membership = SearchSpaceMembership(
|
||||
user_id=owner_user_id,
|
||||
search_space_id=search_space_id,
|
||||
role_id=owner_role_id,
|
||||
is_owner=True,
|
||||
)
|
||||
session.add(owner_membership)
|
||||
|
||||
|
||||
@router.post("/searchspaces", response_model=SearchSpaceRead)
|
||||
async def create_search_space(
|
||||
search_space: SearchSpaceCreate,
|
||||
|
|
@ -27,6 +86,11 @@ async def create_search_space(
|
|||
|
||||
db_search_space = SearchSpace(**search_space_data, user_id=user.id)
|
||||
session.add(db_search_space)
|
||||
await session.flush() # Get the search space ID
|
||||
|
||||
# Create default roles and owner membership
|
||||
await create_default_roles_and_membership(session, db_search_space.id, user.id)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(db_search_space)
|
||||
return db_search_space
|
||||
|
|
@ -34,26 +98,86 @@ async def create_search_space(
|
|||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Failed to create search space: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to create search space: {e!s}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/searchspaces", response_model=list[SearchSpaceRead])
|
||||
@router.get("/searchspaces", response_model=list[SearchSpaceWithStats])
|
||||
async def read_search_spaces(
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
owned_only: bool = False,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get all search spaces the user has access to, with member count and ownership info.
|
||||
|
||||
Args:
|
||||
skip: Number of items to skip
|
||||
limit: Maximum number of items to return
|
||||
owned_only: If True, only return search spaces owned by the user.
|
||||
If False (default), return all search spaces the user has access to.
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
if owned_only:
|
||||
# Return only search spaces where user is the original creator (user_id)
|
||||
result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
else:
|
||||
# Return all search spaces the user has membership in
|
||||
result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
search_spaces = result.scalars().all()
|
||||
|
||||
# Get member counts and ownership info for each search space
|
||||
search_spaces_with_stats = []
|
||||
for space in search_spaces:
|
||||
# Get member count
|
||||
count_result = await session.execute(
|
||||
select(func.count(SearchSpaceMembership.id)).filter(
|
||||
SearchSpaceMembership.search_space_id == space.id
|
||||
)
|
||||
)
|
||||
member_count = count_result.scalar() or 1
|
||||
|
||||
# Check if current user is owner
|
||||
ownership_result = await session.execute(
|
||||
select(SearchSpaceMembership).filter(
|
||||
SearchSpaceMembership.search_space_id == space.id,
|
||||
SearchSpaceMembership.user_id == user.id,
|
||||
SearchSpaceMembership.is_owner == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
is_owner = ownership_result.scalars().first() is not None
|
||||
|
||||
search_spaces_with_stats.append(
|
||||
SearchSpaceWithStats(
|
||||
id=space.id,
|
||||
name=space.name,
|
||||
description=space.description,
|
||||
created_at=space.created_at,
|
||||
user_id=space.user_id,
|
||||
citations_enabled=space.citations_enabled,
|
||||
qna_custom_instructions=space.qna_custom_instructions,
|
||||
member_count=member_count,
|
||||
is_owner=is_owner,
|
||||
)
|
||||
)
|
||||
|
||||
return search_spaces_with_stats
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch search spaces: {e!s}"
|
||||
|
|
@ -97,10 +221,22 @@ async def read_search_space(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific search space by ID.
|
||||
Requires SETTINGS_VIEW permission or membership.
|
||||
"""
|
||||
try:
|
||||
search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
# Check if user has access (is a member)
|
||||
await check_search_space_access(session, user, search_space_id)
|
||||
|
||||
result = await session.execute(
|
||||
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||
)
|
||||
search_space = result.scalars().first()
|
||||
|
||||
if not search_space:
|
||||
raise HTTPException(status_code=404, detail="Search space not found")
|
||||
|
||||
return search_space
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -118,10 +254,28 @@ async def update_search_space(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a search space.
|
||||
Requires SETTINGS_UPDATE permission.
|
||||
"""
|
||||
try:
|
||||
db_search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
# Check permission
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.SETTINGS_UPDATE.value,
|
||||
"You don't have permission to update this search space",
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||
)
|
||||
db_search_space = result.scalars().first()
|
||||
|
||||
if not db_search_space:
|
||||
raise HTTPException(status_code=404, detail="Search space not found")
|
||||
|
||||
update_data = search_space_update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_search_space, key, value)
|
||||
|
|
@ -143,10 +297,28 @@ async def delete_search_space(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a search space.
|
||||
Requires SETTINGS_DELETE permission (only owners have this by default).
|
||||
"""
|
||||
try:
|
||||
db_search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
# Check permission - only those with SETTINGS_DELETE can delete
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.SETTINGS_DELETE.value,
|
||||
"You don't have permission to delete this search space",
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||
)
|
||||
db_search_space = result.scalars().first()
|
||||
|
||||
if not db_search_space:
|
||||
raise HTTPException(status_code=404, detail="Search space not found")
|
||||
|
||||
await session.delete(db_search_space)
|
||||
await session.commit()
|
||||
return {"message": "Search space deleted successfully"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue