refactor(routes): rename to workspace + consolidate URL spellings (Phase 2 Wave E)

Hard cutover of the HTTP surface: collapse the three legacy spellings
(/searchspaces, /search-spaces, /search-space/{id}) onto canonical
/workspaces/{workspace_id}/..., rename the gateway field-setter sub-actions to
singular /workspace, rename search_spaces_routes.py -> workspaces_routes.py and the
search_spaces_router -> workspaces_router include, and flip search_space_id ->
workspace_id in bodies/params. No alias routers: the old URLs now 404 by design.
Full app constructs with zero legacy path spellings.
This commit is contained in:
CREDO23 2026-06-26 18:35:08 +02:00
parent 7fb0707933
commit 56826a63bc
44 changed files with 1247 additions and 1247 deletions

View file

@ -61,7 +61,7 @@ from .rbac_routes import router as rbac_router
from .reports_routes import router as reports_router
from .sandbox_routes import router as sandbox_router
from .search_source_connectors_routes import router as search_source_connectors_router
from .search_spaces_routes import router as search_spaces_router
from .workspaces_routes import router as workspaces_router
from .slack_add_connector_route import router as slack_add_connector_router
from .stripe_routes import router as stripe_router
from .team_memory_routes import router as team_memory_router
@ -71,7 +71,7 @@ from .youtube_routes import router as youtube_router
router = APIRouter()
router.include_router(search_spaces_router)
router.include_router(workspaces_router)
router.include_router(rbac_router) # RBAC routes for roles, members, invites
router.include_router(editor_router)
router.include_router(export_router)
@ -92,7 +92,7 @@ router.include_router(agent_revert_router) # POST /threads/{id}/revert/{action_
router.include_router(agent_action_log_router) # GET /threads/{id}/actions
router.include_router(
agent_permissions_router
) # CRUD for /searchspaces/{id}/agent/permissions/rules
) # CRUD for /workspaces/{id}/agent/permissions/rules
router.include_router(agent_flags_router) # GET /agent/flags
router.include_router(sandbox_router) # Sandbox file downloads (Daytona)
router.include_router(chat_comments_router)
@ -135,6 +135,6 @@ router.include_router(stripe_router) # Stripe checkout for additional page pack
router.include_router(youtube_router) # YouTube playlist resolution
router.include_router(prompts_router)
router.include_router(memory_router) # User personal memory (memory.md style)
router.include_router(team_memory_router) # Search-space team memory
router.include_router(team_memory_router) # Workspace team memory
router.include_router(automations_router) # Automations CRUD + run history
router.include_router(file_storage_router) # Original file metadata + download

View file

@ -55,7 +55,7 @@ class AgentActionRead(BaseModel):
id: int
thread_id: int
user_id: str | None
search_space_id: int
workspace_id: int
tool_name: str
args: dict[str, Any] | None
result_id: str | None
@ -116,7 +116,7 @@ async def list_thread_actions(
"""List agent actions for a thread, newest first.
Authorization:
* Caller must be a member of the thread's search space with
* Caller must be a member of the thread's workspace with
``CHATS_READ`` permission.
Pagination:
@ -133,7 +133,7 @@ async def list_thread_actions(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to view this thread's action log.",
)
@ -169,7 +169,7 @@ async def list_thread_actions(
id=row.id,
thread_id=row.thread_id,
user_id=str(row.user_id) if row.user_id is not None else None,
search_space_id=row.search_space_id,
workspace_id=row.workspace_id,
tool_name=row.tool_name,
args=row.args,
result_id=row.result_id,

View file

@ -3,12 +3,12 @@
Surfaces the permission rules consumed by
:class:`PermissionMiddleware`. Rules are scoped at one of three levels:
* **Search-space wide** both ``user_id`` and ``thread_id`` are NULL.
* **Workspace wide** both ``user_id`` and ``thread_id`` are NULL.
* **Per-user** ``user_id`` set, ``thread_id`` NULL.
* **Per-thread** ``thread_id`` set (``user_id`` typically NULL).
The middleware reads these rows at agent build time (see
``chat_deepagent.py``). UI lets a search-space owner curate them so
``chat_deepagent.py``). UI lets a workspace owner curate them so
the agent can ask for approval / auto-deny / auto-allow specific
tool patterns.
@ -36,7 +36,7 @@ from app.db import (
AgentPermissionRule,
NewChatThread,
Permission,
SearchSpace,
Workspace,
get_async_session,
)
from app.users import get_auth_context
@ -58,7 +58,7 @@ _PERMISSION_PATTERN = re.compile(r"^[a-zA-Z0-9_:.\-*]+$")
class AgentPermissionRuleRead(BaseModel):
id: int
search_space_id: int
workspace_id: int
user_id: str | None
thread_id: int | None
permission: str
@ -122,7 +122,7 @@ def _validate_permission_string(value: str) -> str:
def _to_read(row: AgentPermissionRule) -> AgentPermissionRuleRead:
return AgentPermissionRuleRead(
id=row.id,
search_space_id=row.search_space_id,
workspace_id=row.workspace_id,
user_id=str(row.user_id) if row.user_id is not None else None,
thread_id=row.thread_id,
permission=row.permission,
@ -132,17 +132,17 @@ def _to_read(row: AgentPermissionRule) -> AgentPermissionRuleRead:
)
async def _ensure_search_space_membership_admin(
session: AsyncSession, auth: AuthContext, search_space_id: int
async def _ensure_workspace_membership_admin(
session: AsyncSession, auth: AuthContext, workspace_id: int
) -> None:
"""Curating agent rules == "settings" administration on the space."""
space = await session.get(SearchSpace, search_space_id)
space = await session.get(Workspace, workspace_id)
if space is None:
raise HTTPException(status_code=404, detail="Search space not found.")
raise HTTPException(status_code=404, detail="Workspace not found.")
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.SETTINGS_UPDATE.value,
"You don't have permission to manage agent permission rules in this space.",
)
@ -154,21 +154,21 @@ async def _ensure_search_space_membership_admin(
@router.get(
"/searchspaces/{search_space_id}/agent/permissions/rules",
"/workspaces/{workspace_id}/agent/permissions/rules",
response_model=list[AgentPermissionRuleRead],
)
async def list_rules(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> list[AgentPermissionRuleRead]:
user = auth.user
_flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id)
await _ensure_workspace_membership_admin(session, user, workspace_id)
stmt = (
select(AgentPermissionRule)
.where(AgentPermissionRule.search_space_id == search_space_id)
.where(AgentPermissionRule.workspace_id == workspace_id)
.order_by(AgentPermissionRule.created_at.desc(), AgentPermissionRule.id.desc())
)
rows = (await session.execute(stmt)).scalars().all()
@ -176,33 +176,33 @@ async def list_rules(
@router.post(
"/searchspaces/{search_space_id}/agent/permissions/rules",
"/workspaces/{workspace_id}/agent/permissions/rules",
response_model=AgentPermissionRuleRead,
status_code=201,
)
async def create_rule(
search_space_id: int,
workspace_id: int,
payload: AgentPermissionRuleCreate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> AgentPermissionRuleRead:
user = auth.user
_flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id)
await _ensure_workspace_membership_admin(session, user, workspace_id)
permission = _validate_permission_string(payload.permission.strip())
pattern = payload.pattern.strip() or "*"
if payload.thread_id is not None:
thread = await session.get(NewChatThread, payload.thread_id)
if thread is None or thread.search_space_id != search_space_id:
if thread is None or thread.workspace_id != workspace_id:
raise HTTPException(
status_code=404,
detail="Thread not found in this search space.",
detail="Thread not found in this workspace.",
)
row = AgentPermissionRule(
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=payload.user_id,
thread_id=payload.thread_id,
permission=permission,
@ -226,11 +226,11 @@ async def create_rule(
@router.patch(
"/searchspaces/{search_space_id}/agent/permissions/rules/{rule_id}",
"/workspaces/{workspace_id}/agent/permissions/rules/{rule_id}",
response_model=AgentPermissionRuleRead,
)
async def update_rule(
search_space_id: int,
workspace_id: int,
rule_id: int,
payload: AgentPermissionRuleUpdate,
session: AsyncSession = Depends(get_async_session),
@ -238,10 +238,10 @@ async def update_rule(
) -> AgentPermissionRuleRead:
user = auth.user
_flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id)
await _ensure_workspace_membership_admin(session, user, workspace_id)
row = await session.get(AgentPermissionRule, rule_id)
if row is None or row.search_space_id != search_space_id:
if row is None or row.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail="Rule not found.")
if payload.pattern is not None:
@ -262,21 +262,21 @@ async def update_rule(
@router.delete(
"/searchspaces/{search_space_id}/agent/permissions/rules/{rule_id}",
"/workspaces/{workspace_id}/agent/permissions/rules/{rule_id}",
status_code=204,
)
async def delete_rule(
search_space_id: int,
workspace_id: int,
rule_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> None:
user = auth.user
_flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id)
await _ensure_workspace_membership_admin(session, user, workspace_id)
row = await session.get(AgentPermissionRule, rule_id)
if row is None or row.search_space_id != search_space_id:
if row is None or row.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail="Rule not found.")
await session.delete(row)

View file

@ -86,7 +86,7 @@ async def connect_airtable(
Initiate Airtable OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -317,7 +317,7 @@ async def airtable_callback(
connector_type=SearchSourceConnectorType.AIRTABLE_CONNECTOR,
is_indexable=False,
config=credentials_dict,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)

View file

@ -101,9 +101,9 @@ async def remove_comment(
@router.get("/mentions", response_model=MentionListResponse)
async def list_mentions(
search_space_id: int | None = None,
workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(require_session_context),
):
"""List mentions for the current user."""
return await get_user_mentions(session, auth, search_space_id)
return await get_user_mentions(session, auth, workspace_id)

View file

@ -2,7 +2,7 @@
Circleback Webhook Route
This module provides a webhook endpoint for receiving meeting data from Circleback.
It processes the incoming webhook payload and saves it as a document in the specified search space.
It processes the incoming webhook payload and saves it as a document in the specified workspace.
"""
import logging
@ -212,9 +212,9 @@ def format_circleback_meeting_to_markdown(payload: CirclebackWebhookPayload) ->
return "\n".join(lines)
@router.post("/webhooks/circleback/{search_space_id}")
@router.post("/webhooks/circleback/{workspace_id}")
async def receive_circleback_webhook(
search_space_id: int,
workspace_id: int,
payload: CirclebackWebhookPayload,
session: AsyncSession = Depends(get_async_session),
):
@ -222,11 +222,11 @@ async def receive_circleback_webhook(
Receive and process a Circleback webhook.
This endpoint receives meeting data from Circleback and saves it as a document
in the specified search space. The meeting data is converted to Markdown format
in the specified workspace. The meeting data is converted to Markdown format
and processed asynchronously.
Args:
search_space_id: The ID of the search space to save the document to
workspace_id: The ID of the workspace to save the document to
payload: The Circleback webhook payload containing meeting data
session: Database session for looking up the connector
@ -239,13 +239,13 @@ async def receive_circleback_webhook(
"""
try:
logger.info(
f"Received Circleback webhook for meeting {payload.id} in search space {search_space_id}"
f"Received Circleback webhook for meeting {payload.id} in workspace {workspace_id}"
)
# Look up the Circleback connector for this search space
# Look up the Circleback connector for this workspace
connector_result = await session.execute(
select(SearchSourceConnector.id).where(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CIRCLEBACK_CONNECTOR,
)
@ -254,11 +254,11 @@ async def receive_circleback_webhook(
if connector_id:
logger.info(
f"Found Circleback connector {connector_id} for search space {search_space_id}"
f"Found Circleback connector {connector_id} for workspace {workspace_id}"
)
else:
logger.warning(
f"No Circleback connector found for search space {search_space_id}. "
f"No Circleback connector found for workspace {workspace_id}. "
"Document will be created without connector_id."
)
@ -289,19 +289,19 @@ async def receive_circleback_webhook(
meeting_name=payload.name,
markdown_content=markdown_content,
metadata=meeting_metadata,
search_space_id=search_space_id,
workspace_id=workspace_id,
connector_id=connector_id,
)
logger.info(
f"Queued Circleback meeting {payload.id} for processing in search space {search_space_id}"
f"Queued Circleback meeting {payload.id} for processing in workspace {workspace_id}"
)
return {
"status": "accepted",
"message": f"Meeting '{payload.name}' queued for processing",
"meeting_id": payload.id,
"search_space_id": search_space_id,
"workspace_id": workspace_id,
}
except Exception as e:
@ -312,9 +312,9 @@ async def receive_circleback_webhook(
) from e
@router.get("/webhooks/circleback/{search_space_id}/info")
@router.get("/webhooks/circleback/{workspace_id}/info")
async def get_circleback_webhook_info(
search_space_id: int,
workspace_id: int,
):
"""
Get information about the Circleback webhook endpoint.
@ -323,7 +323,7 @@ async def get_circleback_webhook_info(
webhook integration.
Args:
search_space_id: The ID of the search space
workspace_id: The ID of the workspace
Returns:
Webhook configuration information
@ -332,11 +332,11 @@ async def get_circleback_webhook_info(
# Construct the webhook URL
base_url = getattr(config, "API_BASE_URL", "http://localhost:8000")
webhook_url = f"{base_url}/api/v1/webhooks/circleback/{search_space_id}"
webhook_url = f"{base_url}/api/v1/webhooks/circleback/{workspace_id}"
return {
"webhook_url": webhook_url,
"search_space_id": search_space_id,
"workspace_id": workspace_id,
"method": "POST",
"content_type": "application/json",
"description": "Use this URL in your Circleback automation to send meeting data to SurfSense",

View file

@ -69,7 +69,7 @@ async def connect_clickup(
Initiate ClickUp OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -290,10 +290,10 @@ async def clickup_callback(
"_token_encrypted": True,
}
# Check if connector already exists for this search space and user
# Check if connector already exists for this workspace and user
existing_connector_result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CLICKUP_CONNECTOR,
@ -316,7 +316,7 @@ async def clickup_callback(
connector_type=SearchSourceConnectorType.CLICKUP_CONNECTOR,
is_indexable=False,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)

View file

@ -41,7 +41,7 @@ from app.utils.connector_naming import (
get_base_name_for_type,
)
from app.utils.oauth_security import OAuthStateManager
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@ -108,7 +108,7 @@ async def initiate_composio_auth(
Initiate Composio OAuth flow for a specific toolkit.
Query params:
space_id: Search space ID to add connector to
space_id: Workspace ID to add connector to
toolkit_id: Composio toolkit ID (e.g., "googledrive", "gmail", "googlecalendar")
Returns:
@ -334,7 +334,7 @@ async def composio_callback(
existing_connector_result = await session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.connector_type == connector_type,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.name == connector_name,
)
@ -395,7 +395,7 @@ async def composio_callback(
name=connector_name,
connector_type=connector_type,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
is_indexable=toolkit_id in INDEXABLE_TOOLKITS,
)
@ -461,7 +461,7 @@ async def reauth_composio_connector(
after the user completes the OAuth flow again.
Query params:
space_id: Search space ID the connector belongs to
space_id: Workspace ID the connector belongs to
connector_id: ID of the existing Composio connector to re-authenticate
return_url: Optional frontend path to redirect to after completion
"""
@ -481,7 +481,7 @@ async def reauth_composio_connector(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type.in_(COMPOSIO_CONNECTOR_TYPES),
)
)
@ -594,7 +594,7 @@ async def composio_reauth_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
)
)
connector = result.scalars().first()
@ -683,7 +683,7 @@ async def list_composio_drive_folders(
detail="Composio Google Drive connector not found or access denied",
)
await check_search_space_access(session, auth, connector.search_space_id)
await check_workspace_access(session, auth, connector.workspace_id)
composio_connected_account_id = connector.config.get(
"composio_connected_account_id"

View file

@ -85,7 +85,7 @@ async def connect_confluence(
Initiate Confluence OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -309,7 +309,7 @@ async def confluence_callback(
sa_select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
)
@ -375,7 +375,7 @@ async def confluence_callback(
connector_type=SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@ -437,7 +437,7 @@ async def reauth_confluence(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
)

View file

@ -30,7 +30,7 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@ -86,7 +86,7 @@ async def connect_discord(
Initiate Discord OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -333,7 +333,7 @@ async def discord_callback(
connector_type=SearchSourceConnectorType.DISCORD_CONNECTOR,
is_indexable=False,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@ -652,7 +652,7 @@ async def get_discord_channels(
detail="Discord connector not found or access denied",
)
await check_search_space_access(session, auth, connector.search_space_id)
await check_workspace_access(session, auth, connector.workspace_id)
# Get credentials and decrypt bot token
credentials = DiscordAuthCredentialsBase.from_dict(connector.config)

View file

@ -16,8 +16,8 @@ from app.db import (
DocumentVersion,
Folder,
Permission,
SearchSpace,
SearchSpaceMembership,
Workspace,
WorkspaceMembership,
get_async_session,
)
from app.schemas import (
@ -72,9 +72,9 @@ async def create_documents(
await check_permission(
session,
auth,
request.search_space_id,
request.workspace_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
"You don't have permission to create documents in this workspace",
)
if request.document_type == DocumentType.EXTENSION:
@ -96,14 +96,14 @@ async def create_documents(
"pageContent": individual_document.pageContent,
}
process_extension_document_task.delay(
document_dict, request.search_space_id, str(user.id)
document_dict, request.workspace_id, str(user.id)
)
elif request.document_type == DocumentType.YOUTUBE_VIDEO:
from app.tasks.celery_tasks.document_tasks import process_youtube_video_task
for url in request.content:
process_youtube_video_task.delay(
url, request.search_space_id, str(user.id)
url, request.workspace_id, str(user.id)
)
else:
raise HTTPException(status_code=400, detail="Invalid document type")
@ -125,7 +125,7 @@ async def create_documents(
@router.post("/documents/fileupload")
async def create_documents_file_upload(
files: list[UploadFile],
search_space_id: int = Form(...),
workspace_id: int = Form(...),
use_vision_llm: bool = Form(False),
processing_mode: str = Form("basic"),
session: AsyncSession = Depends(get_async_session),
@ -162,9 +162,9 @@ async def create_documents_file_upload(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
"You don't have permission to create documents in this workspace",
)
if not files:
@ -216,7 +216,7 @@ async def create_documents_file_upload(
for temp_path, filename, file_size, content_type in saved_files:
try:
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.FILE, filename, search_space_id
DocumentType.FILE, filename, workspace_id
)
existing = await check_document_by_unique_identifier(
@ -244,7 +244,7 @@ async def create_documents_file_upload(
continue
document = Document(
search_space_id=search_space_id,
workspace_id=workspace_id,
title=filename if filename != "unknown" else "Uploaded File",
document_type=DocumentType.FILE,
document_metadata={
@ -288,7 +288,7 @@ async def create_documents_file_upload(
await store_document_file(
session,
document_id=document.id,
search_space_id=search_space_id,
workspace_id=workspace_id,
data=original_bytes,
filename=filename,
mime_type=content_type,
@ -308,7 +308,7 @@ async def create_documents_file_upload(
document_id=document.id,
temp_path=temp_path,
filename=filename,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=str(user.id),
use_vision_llm=use_vision_llm,
processing_mode=validated_mode.value,
@ -336,7 +336,7 @@ async def read_documents(
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
search_space_id: int | None = None,
workspace_id: int | None = None,
document_types: str | None = None,
folder_id: int | str | None = None,
sort_by: str = "created_at",
@ -347,13 +347,13 @@ async def read_documents(
user = auth.user
"""
List documents the user has access to, with optional filtering and pagination.
Requires DOCUMENTS_READ permission for the search space(s).
Requires DOCUMENTS_READ permission for the workspace(s).
Args:
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'.
page: Zero-based page index used when 'skip' is not provided.
page_size: Number of items per page (default: 50). Use -1 to return all remaining items after the offset.
search_space_id: If provided, restrict results to a specific search space.
workspace_id: If provided, restrict results to a specific workspace.
document_types: Comma-separated list of document types to filter by (e.g., "EXTENSION,FILE,SLACK_CONNECTOR").
session: Database session (injected).
user: Current authenticated user (injected).
@ -363,45 +363,45 @@ async def read_documents(
Notes:
- If both 'skip' and 'page' are provided, 'skip' is used.
- Results are scoped to documents in search spaces the user has membership in.
- Results are scoped to documents in workspaces the user has membership in.
"""
try:
from sqlalchemy import func
# If specific search_space_id, check permission
if search_space_id is not None:
# If specific workspace_id, check permission
if workspace_id is not None:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
query = (
select(Document)
.options(selectinload(Document.created_by))
.filter(Document.search_space_id == search_space_id)
.filter(Document.workspace_id == workspace_id)
)
count_query = (
select(func.count())
.select_from(Document)
.filter(Document.search_space_id == search_space_id)
.filter(Document.workspace_id == workspace_id)
)
else:
# Get documents from all search spaces user has membership in
# Get documents from all workspaces user has membership in
query = (
select(Document)
.options(selectinload(Document.created_by))
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
)
count_query = (
select(func.count())
.select_from(Document)
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
)
# Filter by document_types if provided
@ -483,7 +483,7 @@ async def read_documents(
unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
workspace_id=doc.workspace_id,
folder_id=doc.folder_id,
created_by_id=doc.created_by_id,
created_by_name=created_by_name,
@ -519,22 +519,22 @@ async def search_documents(
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
search_space_id: int | None = None,
workspace_id: int | None = None,
document_types: str | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Search documents by title substring, optionally filtered by search_space_id and document_types.
Requires DOCUMENTS_READ permission for the search space(s).
Search documents by title substring, optionally filtered by workspace_id and document_types.
Requires DOCUMENTS_READ permission for the workspace(s).
Args:
title: Case-insensitive substring to match against document titles. Required.
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'. Default: None.
page: Zero-based page index used when 'skip' is not provided. Default: None.
page_size: Number of items per page. Use -1 to return all remaining items after the offset. Default: 50.
search_space_id: Filter results to a specific search space. Default: None.
workspace_id: Filter results to a specific workspace. Default: None.
document_types: Comma-separated list of document types to filter by (e.g., "EXTENSION,FILE,SLACK_CONNECTOR").
session: Database session (injected).
user: Current authenticated user (injected).
@ -549,40 +549,40 @@ async def search_documents(
try:
from sqlalchemy import func
# If specific search_space_id, check permission
if search_space_id is not None:
# If specific workspace_id, check permission
if workspace_id is not None:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
query = (
select(Document)
.options(selectinload(Document.created_by))
.filter(Document.search_space_id == search_space_id)
.filter(Document.workspace_id == workspace_id)
)
count_query = (
select(func.count())
.select_from(Document)
.filter(Document.search_space_id == search_space_id)
.filter(Document.workspace_id == workspace_id)
)
else:
# Get documents from all search spaces user has membership in
# Get documents from all workspaces user has membership in
query = (
select(Document)
.options(selectinload(Document.created_by))
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
)
count_query = (
select(func.count())
.select_from(Document)
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
)
# Only search by title (case-insensitive)
@ -644,7 +644,7 @@ async def search_documents(
unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
workspace_id=doc.workspace_id,
folder_id=doc.folder_id,
created_by_id=doc.created_by_id,
created_by_name=created_by_name,
@ -676,7 +676,7 @@ async def search_documents(
@router.get("/documents/search/titles", response_model=DocumentTitleSearchResponse)
async def search_document_titles(
search_space_id: int,
workspace_id: int,
title: str = "",
page: int = 0,
page_size: int = 20,
@ -691,7 +691,7 @@ async def search_document_titles(
Results are ordered by relevance using trigram similarity scores.
Args:
search_space_id: The search space to search in. Required.
workspace_id: The workspace to search in. Required.
title: Search query (case-insensitive). If empty or < 2 chars, returns recent documents.
page: Zero-based page index. Default: 0.
page_size: Number of items per page. Default: 20.
@ -704,13 +704,13 @@ async def search_document_titles(
from sqlalchemy import desc, func, or_
try:
# Check permission for the search space
# Check permission for the workspace
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
# Base query - only select lightweight fields
@ -718,7 +718,7 @@ async def search_document_titles(
Document.id,
Document.title,
Document.document_type,
).filter(Document.search_space_id == search_space_id)
).filter(Document.workspace_id == workspace_id)
# If query is too short, return recent documents ordered by updated_at
if len(title.strip()) < 2:
@ -782,7 +782,7 @@ async def search_document_titles(
@router.get("/documents/by-virtual-path", response_model=DocumentTitleRead)
async def get_document_by_virtual_path(
search_space_id: int,
workspace_id: int,
virtual_path: str,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -809,14 +809,14 @@ async def get_document_by_virtual_path(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
document = await virtual_path_to_doc(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
virtual_path=virtual_path,
)
if document is None:
@ -839,13 +839,13 @@ async def get_document_by_virtual_path(
@router.get("/documents/status", response_model=DocumentStatusBatchResponse)
async def get_documents_status(
search_space_id: int,
workspace_id: int,
document_ids: str,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
Batch status endpoint for documents in a search space.
Batch status endpoint for documents in a workspace.
Returns lightweight status info for the provided document IDs, intended for
polling async ETL progress in chat upload flows.
@ -854,9 +854,9 @@ async def get_documents_status(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
# Parse comma-separated IDs (e.g. "1,2,3")
@ -878,7 +878,7 @@ async def get_documents_status(
result = await session.execute(
select(Document).filter(
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
Document.id.in_(parsed_ids),
)
)
@ -907,17 +907,17 @@ async def get_documents_status(
@router.get("/documents/type-counts")
async def get_document_type_counts(
search_space_id: int | None = None,
workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Get counts of documents by type for search spaces the user has access to.
Requires DOCUMENTS_READ permission for the search space(s).
Get counts of documents by type for workspaces the user has access to.
Requires DOCUMENTS_READ permission for the workspace(s).
Args:
search_space_id: If provided, restrict counts to a specific search space.
workspace_id: If provided, restrict counts to a specific workspace.
session: Database session (injected).
user: Current authenticated user (injected).
@ -927,27 +927,27 @@ async def get_document_type_counts(
try:
from sqlalchemy import func
if search_space_id is not None:
# Check permission for specific search space
if workspace_id is not None:
# Check permission for specific workspace
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
query = (
select(Document.document_type, func.count(Document.id))
.filter(Document.search_space_id == search_space_id)
.filter(Document.workspace_id == workspace_id)
.group_by(Document.document_type)
)
else:
# Get counts from all search spaces user has membership in
# Get counts from all workspaces user has membership in
query = (
select(Document.document_type, func.count(Document.id))
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
.group_by(Document.document_type)
)
@ -1001,9 +1001,9 @@ async def get_document_by_chunk_id(
await check_permission(
session,
auth,
document.search_space_id,
document.workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
total_result = await session.execute(
@ -1048,7 +1048,7 @@ async def get_document_by_chunk_id(
unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
updated_at=document.updated_at,
search_space_id=document.search_space_id,
workspace_id=document.workspace_id,
chunks=windowed_chunks,
total_chunks=total_chunks,
chunk_start_index=start,
@ -1063,7 +1063,7 @@ async def get_document_by_chunk_id(
@router.get("/documents/watched-folders", response_model=list[FolderRead])
async def get_watched_folders(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
@ -1071,16 +1071,16 @@ async def get_watched_folders(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
folders = (
(
await session.execute(
select(Folder).where(
Folder.search_space_id == search_space_id,
Folder.workspace_id == workspace_id,
Folder.parent_id.is_(None),
Folder.folder_metadata.isnot(None),
Folder.folder_metadata["watched"].astext == "true",
@ -1126,9 +1126,9 @@ async def get_document_chunks_paginated(
await check_permission(
session,
auth,
document.search_space_id,
document.workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
total_result = await session.execute(
@ -1171,7 +1171,7 @@ async def read_document(
):
"""
Get a specific document by ID.
Requires DOCUMENTS_READ permission for the search space.
Requires DOCUMENTS_READ permission for the workspace.
"""
try:
result = await session.execute(
@ -1184,13 +1184,13 @@ async def read_document(
status_code=404, detail=f"Document with id {document_id} not found"
)
# Check permission for the search space
# Check permission for the workspace
await check_permission(
session,
auth,
document.search_space_id,
document.workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
raw_content = document.content or ""
@ -1205,7 +1205,7 @@ async def read_document(
unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
updated_at=document.updated_at,
search_space_id=document.search_space_id,
workspace_id=document.workspace_id,
folder_id=document.folder_id,
)
except HTTPException:
@ -1225,7 +1225,7 @@ async def update_document(
):
"""
Update a document.
Requires DOCUMENTS_UPDATE permission for the search space.
Requires DOCUMENTS_UPDATE permission for the workspace.
"""
try:
result = await session.execute(
@ -1238,13 +1238,13 @@ async def update_document(
status_code=404, detail=f"Document with id {document_id} not found"
)
# Check permission for the search space
# Check permission for the workspace
await check_permission(
session,
auth,
db_document.search_space_id,
db_document.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update documents in this search space",
"You don't have permission to update documents in this workspace",
)
update_data = document_update.model_dump(exclude_unset=True)
@ -1264,7 +1264,7 @@ async def update_document(
unique_identifier_hash=db_document.unique_identifier_hash,
created_at=db_document.created_at,
updated_at=db_document.updated_at,
search_space_id=db_document.search_space_id,
workspace_id=db_document.workspace_id,
folder_id=db_document.folder_id,
)
except HTTPException:
@ -1284,7 +1284,7 @@ async def delete_document(
):
"""
Delete a document.
Requires DOCUMENTS_DELETE permission for the search space.
Requires DOCUMENTS_DELETE permission for the workspace.
Documents in "processing" state cannot be deleted.
Heavy cascade deletion runs asynchronously via Celery so the API
@ -1313,13 +1313,13 @@ async def delete_document(
detail="Document is already being deleted.",
)
# Check permission for the search space
# Check permission for the workspace
await check_permission(
session,
auth,
document.search_space_id,
document.workspace_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete documents in this search space",
"You don't have permission to delete documents in this workspace",
)
# Mark the document as "deleting" so it's excluded from searches,
@ -1371,7 +1371,7 @@ async def list_document_versions(
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
session, user, document.search_space_id, Permission.DOCUMENTS_READ.value
session, user, document.workspace_id, Permission.DOCUMENTS_READ.value
)
versions = (
@ -1413,7 +1413,7 @@ async def get_document_version(
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
session, user, document.search_space_id, Permission.DOCUMENTS_READ.value
session, user, document.workspace_id, Permission.DOCUMENTS_READ.value
)
version = (
@ -1452,7 +1452,7 @@ async def restore_document_version(
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
session, user, document.search_space_id, Permission.DOCUMENTS_UPDATE.value
session, user, document.workspace_id, Permission.DOCUMENTS_UPDATE.value
)
version = (
@ -1503,20 +1503,20 @@ _MAX_MTIME_CHECK_FILES = 10_000
class FolderMtimeCheckRequest(PydanticBaseModel):
folder_name: str
search_space_id: int
workspace_id: int
files: list[FolderMtimeCheckFile] = Field(max_length=_MAX_MTIME_CHECK_FILES)
class FolderUnlinkRequest(PydanticBaseModel):
folder_name: str
search_space_id: int
workspace_id: int
root_folder_id: int | None = None
relative_paths: list[str]
class FolderSyncFinalizeRequest(PydanticBaseModel):
folder_name: str
search_space_id: int
workspace_id: int
root_folder_id: int | None = None
all_relative_paths: list[str]
@ -1537,16 +1537,16 @@ async def folder_mtime_check(
await check_permission(
session,
auth,
request.search_space_id,
request.workspace_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
"You don't have permission to create documents in this workspace",
)
uid_hashes = {}
for f in request.files:
uid = f"{request.folder_name}:{f.relative_path}"
uid_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value, uid, request.search_space_id
DocumentType.LOCAL_FOLDER_FILE.value, uid, request.workspace_id
)
uid_hashes[uid_hash] = f
@ -1589,7 +1589,7 @@ async def folder_mtime_check(
async def folder_upload(
files: list[UploadFile],
folder_name: str = Form(...),
search_space_id: int = Form(...),
workspace_id: int = Form(...),
relative_paths: str = Form(...),
root_folder_id: int | None = Form(None),
use_vision_llm: bool = Form(False),
@ -1613,9 +1613,9 @@ async def folder_upload(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
"You don't have permission to create documents in this workspace",
)
if not files:
@ -1655,9 +1655,9 @@ async def folder_upload(
if root_folder_id:
root_folder = await session.get(Folder, root_folder_id)
if not root_folder or root_folder.search_space_id != search_space_id:
if not root_folder or root_folder.workspace_id != workspace_id:
raise HTTPException(
status_code=404, detail="Root folder not found in this search space"
status_code=404, detail="Root folder not found in this workspace"
)
if not root_folder_id:
@ -1671,7 +1671,7 @@ async def folder_upload(
select(Folder).where(
Folder.name == folder_name,
Folder.parent_id.is_(None),
Folder.search_space_id == search_space_id,
Folder.workspace_id == workspace_id,
)
)
).scalar_one_or_none()
@ -1682,7 +1682,7 @@ async def folder_upload(
else:
root_folder = Folder(
name=folder_name,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=str(user.id),
position="a0",
folder_metadata=watched_metadata,
@ -1721,7 +1721,7 @@ async def folder_upload(
)
index_uploaded_folder_files_task.delay(
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=str(user.id),
folder_name=folder_name,
root_folder_id=root_folder_id,
@ -1756,9 +1756,9 @@ async def folder_unlink(
await check_permission(
session,
auth,
request.search_space_id,
request.workspace_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete documents in this search space",
"You don't have permission to delete documents in this workspace",
)
deleted_count = 0
@ -1768,7 +1768,7 @@ async def folder_unlink(
uid_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value,
unique_id,
request.search_space_id,
request.workspace_id,
)
existing = (
@ -1813,9 +1813,9 @@ async def folder_sync_finalize(
await check_permission(
session,
auth,
request.search_space_id,
request.workspace_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete documents in this search space",
"You don't have permission to delete documents in this workspace",
)
if not request.root_folder_id:
@ -1829,7 +1829,7 @@ async def folder_sync_finalize(
uid_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value,
unique_id,
request.search_space_id,
request.workspace_id,
)
seen_hashes.add(uid_hash)
@ -1838,7 +1838,7 @@ async def folder_sync_finalize(
await session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == request.search_space_id,
Document.workspace_id == request.workspace_id,
Document.folder_id.in_(subtree_ids),
)
)
@ -1866,7 +1866,7 @@ async def folder_sync_finalize(
await _cleanup_empty_folders(
session,
request.root_folder_id,
request.search_space_id,
request.workspace_id,
existing_dirs,
folder_mapping,
subtree_ids=subtree_ids,

View file

@ -36,7 +36,7 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
router = APIRouter()
@ -124,7 +124,7 @@ async def reauth_dropbox(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.DROPBOX_CONNECTOR,
)
@ -304,7 +304,7 @@ async def dropbox_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.DROPBOX_CONNECTOR,
)
@ -372,7 +372,7 @@ async def dropbox_callback(
connector_type=SearchSourceConnectorType.DROPBOX_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
@ -431,7 +431,7 @@ async def list_dropbox_folders(
status_code=404, detail="Dropbox connector not found or access denied"
)
await check_search_space_access(session, auth, connector.search_space_id)
await check_workspace_access(session, auth, connector.workspace_id)
dropbox_client = DropboxClient(session, connector_id)
items, error = await list_folder_contents(dropbox_client, path=parent_path)

View file

@ -43,9 +43,9 @@ EDITOR_PLATE_MAX_BYTES = 1 * 1024 * 1024
EDITOR_PLATE_MAX_LINES = 5000
@router.get("/search-spaces/{search_space_id}/documents/{document_id}/editor-content")
@router.get("/workspaces/{workspace_id}/documents/{document_id}/editor-content")
async def get_editor_content(
search_space_id: int,
workspace_id: int,
document_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -62,15 +62,15 @@ async def get_editor_content(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
result = await session.execute(
select(Document).filter(
Document.id == document_id,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
document = result.scalars().first()
@ -173,10 +173,10 @@ async def get_editor_content(
@router.get(
"/search-spaces/{search_space_id}/documents/{document_id}/download-markdown"
"/workspaces/{workspace_id}/documents/{document_id}/download-markdown"
)
async def download_document_markdown(
search_space_id: int,
workspace_id: int,
document_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -188,15 +188,15 @@ async def download_document_markdown(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
result = await session.execute(
select(Document).filter(
Document.id == document_id,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
document = result.scalars().first()
@ -239,9 +239,9 @@ async def download_document_markdown(
)
@router.post("/search-spaces/{search_space_id}/documents/{document_id}/save")
@router.post("/workspaces/{workspace_id}/documents/{document_id}/save")
async def save_document(
search_space_id: int,
workspace_id: int,
document_id: int,
data: dict[str, Any],
session: AsyncSession = Depends(get_async_session),
@ -262,15 +262,15 @@ async def save_document(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update documents in this search space",
"You don't have permission to update documents in this workspace",
)
result = await session.execute(
select(Document).filter(
Document.id == document_id,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
document = result.scalars().first()
@ -324,9 +324,9 @@ async def save_document(
}
@router.get("/search-spaces/{search_space_id}/documents/{document_id}/export")
@router.get("/workspaces/{workspace_id}/documents/{document_id}/export")
async def export_document(
search_space_id: int,
workspace_id: int,
document_id: int,
format: ExportFormat = Query(
ExportFormat.PDF,
@ -339,15 +339,15 @@ async def export_document(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
"You don't have permission to read documents in this workspace",
)
result = await session.execute(
select(Document).filter(
Document.id == document_id,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
document = result.scalars().first()

View file

@ -18,9 +18,9 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/search-spaces/{search_space_id}/export")
@router.get("/workspaces/{workspace_id}/export")
async def export_knowledge_base(
search_space_id: int,
workspace_id: int,
folder_id: int | None = Query(
None, description="Export only this folder's subtree"
),
@ -31,13 +31,13 @@ async def export_knowledge_base(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to export documents in this search space",
"You don't have permission to export documents in this workspace",
)
try:
result = await build_export_zip(session, search_space_id, folder_id)
result = await build_export_zip(session, workspace_id, folder_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from None

View file

@ -42,32 +42,32 @@ async def create_folder(
await check_permission(
session,
auth,
request.search_space_id,
request.workspace_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create folders in this search space",
"You don't have permission to create folders in this workspace",
)
if request.parent_id is not None:
parent = await session.get(Folder, request.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Parent folder not found")
if parent.search_space_id != request.search_space_id:
if parent.workspace_id != request.workspace_id:
raise HTTPException(
status_code=400,
detail="Parent folder belongs to a different search space",
detail="Parent folder belongs to a different workspace",
)
await validate_folder_depth(session, request.parent_id)
position = await generate_folder_position(
session, request.search_space_id, request.parent_id
session, request.workspace_id, request.parent_id
)
folder = Folder(
name=request.name,
position=position,
parent_id=request.parent_id,
search_space_id=request.search_space_id,
workspace_id=request.workspace_id,
created_by_id=user.id,
)
session.add(folder)
@ -91,23 +91,23 @@ async def create_folder(
@router.get("/folders", response_model=list[FolderRead])
async def list_folders(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""List all folders in a search space (flat). Requires DOCUMENTS_READ permission."""
"""List all folders in a workspace (flat). Requires DOCUMENTS_READ permission."""
try:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
"You don't have permission to read folders in this workspace",
)
result = await session.execute(
select(Folder)
.where(Folder.search_space_id == search_space_id)
.where(Folder.workspace_id == workspace_id)
.order_by(Folder.position)
)
return result.scalars().all()
@ -135,9 +135,9 @@ async def get_folder(
await check_permission(
session,
auth,
folder.search_space_id,
folder.workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
"You don't have permission to read folders in this workspace",
)
return folder
@ -165,9 +165,9 @@ async def get_folder_breadcrumb(
await check_permission(
session,
auth,
folder.search_space_id,
folder.workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
"You don't have permission to read folders in this workspace",
)
result = await session.execute(
@ -208,9 +208,9 @@ async def stop_watching_folder(
await check_permission(
session,
auth,
folder.search_space_id,
folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update folders in this search space",
"You don't have permission to update folders in this workspace",
)
if folder.folder_metadata and isinstance(folder.folder_metadata, dict):
@ -237,9 +237,9 @@ async def update_folder(
await check_permission(
session,
auth,
folder.search_space_id,
folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update folders in this search space",
"You don't have permission to update folders in this workspace",
)
folder.name = request.name
@ -277,9 +277,9 @@ async def move_folder(
await check_permission(
session,
auth,
folder.search_space_id,
folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move folders in this search space",
"You don't have permission to move folders in this workspace",
)
if request.new_parent_id is not None:
@ -288,10 +288,10 @@ async def move_folder(
raise HTTPException(
status_code=404, detail="Target parent folder not found"
)
if new_parent.search_space_id != folder.search_space_id:
if new_parent.workspace_id != folder.workspace_id:
raise HTTPException(
status_code=400,
detail="Cannot move folder to a different search space",
detail="Cannot move folder to a different workspace",
)
await check_no_circular_reference(session, folder_id, request.new_parent_id)
@ -299,7 +299,7 @@ async def move_folder(
await validate_folder_depth(session, request.new_parent_id, subtree_depth)
position = await generate_folder_position(
session, folder.search_space_id, request.new_parent_id
session, folder.workspace_id, request.new_parent_id
)
folder.parent_id = request.new_parent_id
folder.position = position
@ -337,14 +337,14 @@ async def reorder_folder(
await check_permission(
session,
auth,
folder.search_space_id,
folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to reorder folders in this search space",
"You don't have permission to reorder folders in this workspace",
)
position = await generate_folder_position(
session,
folder.search_space_id,
folder.workspace_id,
folder.parent_id,
before_position=request.before_position,
after_position=request.after_position,
@ -378,9 +378,9 @@ async def delete_folder(
await check_permission(
session,
auth,
folder.search_space_id,
folder.workspace_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete folders in this search space",
"You don't have permission to delete folders in this workspace",
)
subtree_ids = await get_folder_subtree_ids(session, folder_id)
@ -455,19 +455,19 @@ async def move_document(
await check_permission(
session,
auth,
document.search_space_id,
document.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move documents in this search space",
"You don't have permission to move documents in this workspace",
)
if request.folder_id is not None:
target = await session.get(Folder, request.folder_id)
if not target:
raise HTTPException(status_code=404, detail="Target folder not found")
if target.search_space_id != document.search_space_id:
if target.workspace_id != document.workspace_id:
raise HTTPException(
status_code=400,
detail="Cannot move document to a folder in a different search space",
detail="Cannot move document to a folder in a different workspace",
)
document.folder_id = request.folder_id
@ -502,14 +502,14 @@ async def bulk_move_documents(
if not documents:
raise HTTPException(status_code=404, detail="No documents found")
search_space_ids = {doc.search_space_id for doc in documents}
for ss_id in search_space_ids:
workspace_ids = {doc.workspace_id for doc in documents}
for ss_id in workspace_ids:
await check_permission(
session,
auth,
ss_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move documents in this search space",
"You don't have permission to move documents in this workspace",
)
if request.folder_id is not None:
@ -519,12 +519,12 @@ async def bulk_move_documents(
mismatched = [
doc.id
for doc in documents
if doc.search_space_id != target.search_space_id
if doc.workspace_id != target.workspace_id
]
if mismatched:
raise HTTPException(
status_code=400,
detail="Cannot move documents to a folder in a different search space",
detail="Cannot move documents to a folder in a different workspace",
)
for doc in documents:

View file

@ -53,7 +53,7 @@ from app.observability.metrics import (
)
from app.users import get_auth_context
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
router = APIRouter(prefix="/gateway", tags=["gateway"])
config_router = APIRouter(prefix="/gateway", tags=["gateway"])
@ -170,7 +170,7 @@ def _slack_event_kind(payload: dict[str, Any]) -> str:
class StartBindingRequest(BaseModel):
platform: ExternalChatPlatform = ExternalChatPlatform.TELEGRAM
search_space_id: int
workspace_id: int
class StartBindingResponse(BaseModel):
@ -180,12 +180,12 @@ class StartBindingResponse(BaseModel):
expires_at: datetime
class UpdateBindingSearchSpaceRequest(BaseModel):
search_space_id: int
class UpdateBindingWorkspaceRequest(BaseModel):
workspace_id: int
class UpdateAccountSearchSpaceRequest(BaseModel):
search_space_id: int
class UpdateAccountWorkspaceRequest(BaseModel):
workspace_id: int
def _active_whatsapp_account_mode() -> ExternalChatAccountMode | None:
@ -249,7 +249,7 @@ def _telegram_message(payload: dict[str, Any]) -> dict[str, Any] | None:
@router.get("/slack/install")
async def install_slack_gateway(
search_space_id: int,
workspace_id: int,
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, str]:
@ -258,8 +258,8 @@ async def install_slack_gateway(
raise HTTPException(
status_code=500, detail="Slack gateway OAuth is not configured"
)
await check_search_space_access(session, auth, search_space_id)
state = _get_state_manager().generate_secure_state(search_space_id, user.id)
await check_workspace_access(session, auth, workspace_id)
state = _get_state_manager().generate_secure_state(workspace_id, user.id)
auth_params = {
"client_id": config.GATEWAY_SLACK_CLIENT_ID,
"scope": ",".join(SLACK_BOT_SCOPES),
@ -382,7 +382,7 @@ async def slack_gateway_callback(
ExternalChatBinding(
account_id=account.id,
user_id=user_id,
search_space_id=space_id,
workspace_id=space_id,
state=ExternalChatBindingState.BOUND,
external_peer_id=peer_id,
external_peer_kind=ExternalChatPeerKind.DIRECT,
@ -395,7 +395,7 @@ async def slack_gateway_callback(
)
)
elif binding.user_id == user_id:
binding.search_space_id = space_id
binding.workspace_id = space_id
binding.external_metadata = {
**(binding.external_metadata or {}),
"kind": "slack_user",
@ -409,7 +409,7 @@ async def slack_gateway_callback(
@router.get("/discord/install")
async def install_discord_gateway(
search_space_id: int,
workspace_id: int,
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, str]:
@ -418,8 +418,8 @@ async def install_discord_gateway(
raise HTTPException(
status_code=500, detail="Discord gateway OAuth is not configured"
)
await check_search_space_access(session, auth, search_space_id)
state = _get_state_manager().generate_secure_state(search_space_id, user.id)
await check_workspace_access(session, auth, workspace_id)
state = _get_state_manager().generate_secure_state(workspace_id, user.id)
auth_params = {
"client_id": config.DISCORD_CLIENT_ID,
"scope": " ".join(DISCORD_GATEWAY_SCOPES),
@ -559,7 +559,7 @@ async def discord_gateway_callback(
ExternalChatBinding(
account_id=account.id,
user_id=user_id,
search_space_id=space_id,
workspace_id=space_id,
state=ExternalChatBindingState.BOUND,
external_peer_id=peer_id,
external_peer_kind=ExternalChatPeerKind.DIRECT,
@ -568,7 +568,7 @@ async def discord_gateway_callback(
)
)
elif binding.user_id == user_id:
binding.search_space_id = space_id
binding.workspace_id = space_id
binding.external_username = discord_username or binding.external_username
binding.external_metadata = {
**(binding.external_metadata or {}),
@ -718,7 +718,7 @@ async def start_binding(
session: AsyncSession = Depends(get_async_session),
) -> StartBindingResponse:
user = auth.user
await check_search_space_access(session, auth, body.search_space_id)
await check_workspace_access(session, auth, body.workspace_id)
code = generate_pairing_code()
if body.platform == ExternalChatPlatform.TELEGRAM:
if not _telegram_gateway_enabled():
@ -758,7 +758,7 @@ async def start_binding(
binding = ExternalChatBinding(
account_id=account.id,
user_id=user.id,
search_space_id=body.search_space_id,
workspace_id=body.workspace_id,
state=ExternalChatBindingState.PENDING,
pairing_code=code,
pairing_code_expires_at=expires_at,
@ -794,7 +794,7 @@ async def list_bindings(
"id": binding.id,
"platform": account.platform.value,
"state": binding.state.value,
"search_space_id": binding.search_space_id,
"workspace_id": binding.workspace_id,
"external_display_name": binding.external_display_name,
"external_username": binding.external_username,
"external_metadata": binding.external_metadata,
@ -869,7 +869,7 @@ async def list_connections(
workspace_id = None
route_type = "binding"
connection_id = binding.id
search_space_id = binding.search_space_id
workspace_id = binding.workspace_id
display_name = binding.external_display_name or binding.external_username
if account.platform == ExternalChatPlatform.SLACK:
workspace_name = account_state.get("team_name")
@ -886,8 +886,8 @@ async def list_connections(
baileys_account_ids.add(int(account.id))
route_type = "account"
connection_id = account.id
search_space_id = (
account.owner_search_space_id or binding.search_space_id
workspace_id = (
account.owner_workspace_id or binding.workspace_id
)
display_name = "WhatsApp Bridge"
@ -899,7 +899,7 @@ async def list_connections(
"platform": account.platform.value,
"mode": account.mode.value,
"state": binding.state.value,
"search_space_id": search_space_id,
"workspace_id": workspace_id,
"display_name": display_name or workspace_name,
"external_username": (
None
@ -921,7 +921,7 @@ async def list_connections(
ExternalChatAccount.owner_user_id == user.id,
ExternalChatAccount.platform == ExternalChatPlatform.WHATSAPP,
ExternalChatAccount.mode == ExternalChatAccountMode.SELF_HOST_BYO,
ExternalChatAccount.owner_search_space_id.is_not(None),
ExternalChatAccount.owner_workspace_id.is_not(None),
)
)
for account in account_result.scalars():
@ -936,7 +936,7 @@ async def list_connections(
"platform": account.platform.value,
"mode": account.mode.value,
"state": "bound",
"search_space_id": account.owner_search_space_id,
"workspace_id": account.owner_workspace_id,
"display_name": "WhatsApp Bridge",
"external_username": None,
"workspace_name": account_state.get("display_phone_number"),
@ -995,10 +995,10 @@ async def get_gateway_config(
}
@router.patch("/bindings/{binding_id}/search-space")
async def update_binding_search_space(
@router.patch("/bindings/{binding_id}/workspace")
async def update_binding_workspace(
binding_id: int,
body: UpdateBindingSearchSpaceRequest,
body: UpdateBindingWorkspaceRequest,
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
@ -1017,19 +1017,19 @@ async def update_binding_search_space(
if account is None or _is_inactive_whatsapp_account(account):
raise HTTPException(status_code=404, detail="Binding not found")
await check_search_space_access(session, auth, body.search_space_id)
if binding.search_space_id != body.search_space_id:
binding.search_space_id = body.search_space_id
await check_workspace_access(session, auth, body.workspace_id)
if binding.workspace_id != body.workspace_id:
binding.workspace_id = body.workspace_id
binding.new_chat_thread_id = None
binding.updated_at = datetime.now(UTC)
await session.commit()
return {"ok": True}
@router.patch("/accounts/{account_id}/search-space")
async def update_gateway_account_search_space(
@router.patch("/accounts/{account_id}/workspace")
async def update_gateway_account_workspace(
account_id: int,
body: UpdateAccountSearchSpaceRequest,
body: UpdateAccountWorkspaceRequest,
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
@ -1044,8 +1044,8 @@ async def update_gateway_account_search_space(
):
raise HTTPException(status_code=404, detail="Gateway account not found")
await check_search_space_access(session, auth, body.search_space_id)
account.owner_search_space_id = body.search_space_id
await check_workspace_access(session, auth, body.workspace_id)
account.owner_workspace_id = body.workspace_id
account.updated_at = datetime.now(UTC)
result = await session.execute(
@ -1058,7 +1058,7 @@ async def update_gateway_account_search_space(
)
)
for binding in result.scalars():
binding.search_space_id = body.search_space_id
binding.workspace_id = body.workspace_id
binding.new_chat_thread_id = None
binding.updated_at = datetime.now(UTC)
@ -1113,7 +1113,7 @@ async def delete_gateway_account(
for binding in result.scalars():
revoke_binding(binding)
account.owner_search_space_id = None
account.owner_workspace_id = None
account.suspended_at = datetime.now(UTC)
account.suspended_reason = "disconnected"
account.updated_at = datetime.now(UTC)

View file

@ -22,13 +22,13 @@ from app.db import (
)
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
from app.users import get_auth_context
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
router = APIRouter(prefix="/gateway/whatsapp/baileys", tags=["gateway"])
class BaileysPairRequest(BaseModel):
search_space_id: int
workspace_id: int
phone_number: str
@ -66,7 +66,7 @@ async def request_pairing_code(
) -> dict[str, Any]:
user = auth.user
_ensure_baileys_enabled()
await check_search_space_access(session, auth, body.search_space_id)
await check_workspace_access(session, auth, body.workspace_id)
adapter = WhatsAppBaileysAdapter()
try:
pairing = await adapter.request_pairing_code(phone_number=body.phone_number)
@ -79,7 +79,7 @@ async def request_pairing_code(
platform=ExternalChatPlatform.WHATSAPP,
mode=ExternalChatAccountMode.SELF_HOST_BYO,
owner_user_id=user.id,
owner_search_space_id=body.search_space_id,
owner_workspace_id=body.workspace_id,
is_system_account=False,
cursor_state={},
health_status=ExternalChatHealthStatus.UNKNOWN,
@ -87,7 +87,7 @@ async def request_pairing_code(
session.add(account)
else:
account.mode = ExternalChatAccountMode.SELF_HOST_BYO
account.owner_search_space_id = body.search_space_id
account.owner_workspace_id = body.workspace_id
account.health_status = ExternalChatHealthStatus.UNKNOWN
account.suspended_at = None
account.suspended_reason = None

View file

@ -141,7 +141,7 @@ async def reauth_calendar(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
)
@ -286,7 +286,7 @@ async def calendar_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
)
@ -343,7 +343,7 @@ async def calendar_callback(
name=connector_name,
connector_type=SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
config=creds_dict,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
is_indexable=False,
)

View file

@ -46,7 +46,7 @@ from app.utils.oauth_security import (
TokenEncryption,
generate_code_verifier,
)
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
# Relax token scope validation for Google OAuth
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
@ -119,7 +119,7 @@ async def connect_drive(
Initiate Google Drive OAuth flow.
Query params:
space_id: Search space ID to add connector to
space_id: Workspace ID to add connector to
Returns:
JSON with auth_url to redirect user to Google authorization
@ -177,7 +177,7 @@ async def reauth_drive(
Initiate Google Drive re-authentication to upgrade OAuth scopes.
Query params:
space_id: Search space ID the connector belongs to
space_id: Workspace ID the connector belongs to
connector_id: ID of the existing connector to re-authenticate
Returns:
@ -189,7 +189,7 @@ async def reauth_drive(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
)
@ -349,7 +349,7 @@ async def drive_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
)
@ -415,7 +415,7 @@ async def drive_callback(
**creds_dict,
"start_page_token": None, # Will be set on first index
},
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
is_indexable=True,
)
@ -517,7 +517,7 @@ async def list_google_drive_folders(
detail="Google Drive connector not found or access denied",
)
await check_search_space_access(session, auth, connector.search_space_id)
await check_workspace_access(session, auth, connector.workspace_id)
# Initialize Drive client (credentials will be loaded on first API call)
drive_client = GoogleDriveClient(session, connector_id)

View file

@ -100,7 +100,7 @@ async def connect_gmail(
Initiate Google Gmail OAuth flow.
Query params:
space_id: Search space ID to add connector to
space_id: Workspace ID to add connector to
Returns:
JSON with auth_url to redirect user to Google authorization
@ -159,7 +159,7 @@ async def reauth_gmail(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
)
@ -317,7 +317,7 @@ async def gmail_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
)
@ -374,7 +374,7 @@ async def gmail_callback(
name=connector_name,
connector_type=SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
config=creds_dict,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
is_indexable=False,
)

View file

@ -22,8 +22,8 @@ from app.db import (
ImageGeneration,
Model,
Permission,
SearchSpace,
SearchSpaceMembership,
Workspace,
WorkspaceMembership,
get_async_session,
)
from app.schemas import (
@ -68,7 +68,7 @@ def _get_global_connection(connection_id: int) -> dict | None:
async def _resolve_billing_for_image_gen(
session: AsyncSession,
config_id: int | None,
search_space: SearchSpace,
workspace: Workspace,
) -> tuple[str, str, int]:
"""Resolve ``(billing_tier, base_model, reserve_micros)`` for a request.
@ -84,18 +84,18 @@ async def _resolve_billing_for_image_gen(
"""
resolved_id = config_id
if resolved_id is None:
resolved_id = search_space.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
resolved_id = workspace.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
if is_image_gen_auto_mode(resolved_id):
candidates = await auto_model_candidates(
session,
search_space_id=search_space.id,
user_id=search_space.user_id,
workspace_id=workspace.id,
user_id=workspace.user_id,
capability="image_gen",
)
if not candidates:
return ("free", "auto", DEFAULT_IMAGE_RESERVE_MICROS)
selected = choose_auto_model_candidate(candidates, search_space.id)
selected = choose_auto_model_candidate(candidates, workspace.id)
resolved_id = int(selected["id"])
if resolved_id < 0:
@ -119,19 +119,19 @@ async def _resolve_billing_for_image_gen(
async def _execute_image_generation(
session: AsyncSession,
image_gen: ImageGeneration,
search_space: SearchSpace,
workspace: Workspace,
) -> None:
"""
Call litellm.aimage_generation() with the appropriate config.
Resolution order:
1. Explicit image_gen_model_id on the request
2. Search space's image_gen_model_id preference
2. Workspace's image_gen_model_id preference
3. Falls back to Auto mode if available
"""
config_id = image_gen.image_gen_model_id
if config_id is None:
config_id = search_space.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
config_id = workspace.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
image_gen.image_gen_model_id = config_id
# Build kwargs
@ -150,13 +150,13 @@ async def _execute_image_generation(
if is_image_gen_auto_mode(config_id):
candidates = await auto_model_candidates(
session,
search_space_id=search_space.id,
user_id=search_space.user_id,
workspace_id=workspace.id,
user_id=workspace.user_id,
capability="image_gen",
)
if not candidates:
raise ValueError("No image-generation models are available for Auto mode")
config_id = int(choose_auto_model_candidate(candidates, search_space.id)["id"])
config_id = int(choose_auto_model_candidate(candidates, workspace.id)["id"])
image_gen.image_gen_model_id = config_id
if config_id < 0:
@ -191,9 +191,9 @@ async def _execute_image_generation(
if not db_model or not db_model.connection or not db_model.connection.enabled:
raise ValueError(f"Image generation model {config_id} not found")
conn = db_model.connection
if conn.search_space_id is not None and conn.search_space_id != search_space.id:
if conn.workspace_id is not None and conn.workspace_id != workspace.id:
raise ValueError(f"Image generation model {config_id} not found")
if conn.user_id is not None and conn.user_id != search_space.user_id:
if conn.user_id is not None and conn.user_id != workspace.user_id:
raise ValueError(f"Image generation model {config_id} not found")
if not has_capability(db_model, "image_gen"):
raise ValueError(f"Model {config_id} is not image-generation capable")
@ -254,7 +254,7 @@ async def create_image_generation(
Premium configs are gated by the user's shared premium credit pool.
The flow is:
1. Permission check + load the search space (cheap, no provider call).
1. Permission check + load the workspace (cheap, no provider call).
2. Resolve which config will run so we know its billing tier and the
worst-case reservation size *before* opening any DB rows.
3. Wrap the entire ImageGeneration row insert + provider call in
@ -273,20 +273,20 @@ async def create_image_generation(
await check_permission(
session,
auth,
data.search_space_id,
data.workspace_id,
Permission.IMAGE_GENERATIONS_CREATE.value,
"You don't have permission to create image generations in this search space",
"You don't have permission to create image generations in this workspace",
)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == data.search_space_id)
select(Workspace).filter(Workspace.id == data.workspace_id)
)
search_space = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
workspace = result.scalars().first()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
billing_tier, base_model, reserve_micros = await _resolve_billing_for_image_gen(
session, data.image_gen_model_id, search_space
session, data.image_gen_model_id, workspace
)
# billable_call runs OUTSIDE the inner try/except so QuotaInsufficientError
@ -296,8 +296,8 @@ async def create_image_generation(
# exists when none does, and (2) return HTTP 200 to a client
# whose request was actively *denied* (issue K).
async with billable_call(
user_id=search_space.user_id,
search_space_id=data.search_space_id,
user_id=workspace.user_id,
workspace_id=data.workspace_id,
billing_tier=billing_tier,
base_model=base_model,
quota_reserve_micros_override=reserve_micros,
@ -313,14 +313,14 @@ async def create_image_generation(
style=data.style,
response_format=data.response_format,
image_gen_model_id=data.image_gen_model_id,
search_space_id=data.search_space_id,
workspace_id=data.workspace_id,
created_by_id=user.id,
)
session.add(db_image_gen)
await session.flush()
try:
await _execute_image_generation(session, db_image_gen, search_space)
await _execute_image_generation(session, db_image_gen, workspace)
except Exception as e:
logger.exception("Image generation call failed")
db_image_gen.error_message = str(e)
@ -363,7 +363,7 @@ async def create_image_generation(
@router.get("/image-generations", response_model=list[ImageGenerationListRead])
async def list_image_generations(
search_space_id: int | None = None,
workspace_id: int | None = None,
skip: int = 0,
limit: int = 50,
session: AsyncSession = Depends(get_async_session),
@ -377,17 +377,17 @@ async def list_image_generations(
limit = 100
try:
if search_space_id is not None:
if workspace_id is not None:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.IMAGE_GENERATIONS_READ.value,
"You don't have permission to read image generations in this search space",
"You don't have permission to read image generations in this workspace",
)
result = await session.execute(
select(ImageGeneration)
.filter(ImageGeneration.search_space_id == search_space_id)
.filter(ImageGeneration.workspace_id == workspace_id)
.order_by(ImageGeneration.created_at.desc())
.offset(skip)
.limit(limit)
@ -395,9 +395,9 @@ async def list_image_generations(
else:
result = await session.execute(
select(ImageGeneration)
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
.order_by(ImageGeneration.created_at.desc())
.offset(skip)
.limit(limit)
@ -434,9 +434,9 @@ async def get_image_generation(
await check_permission(
session,
auth,
image_gen.search_space_id,
image_gen.workspace_id,
Permission.IMAGE_GENERATIONS_READ.value,
"You don't have permission to read image generations in this search space",
"You don't have permission to read image generations in this workspace",
)
return image_gen
@ -466,9 +466,9 @@ async def delete_image_generation(
await check_permission(
session,
auth,
db_image_gen.search_space_id,
db_image_gen.workspace_id,
Permission.IMAGE_GENERATIONS_DELETE.value,
"You don't have permission to delete image generations in this search space",
"You don't have permission to delete image generations in this workspace",
)
await session.delete(db_image_gen)
@ -500,8 +500,8 @@ async def serve_generated_image(
Serve a generated image by ID, protected by a signed token.
The token is generated when the image URL is created by the generate_image
tool and encodes the image_gen_id, search_space_id, and an expiry timestamp.
This ensures only users with access to the search space can view images,
tool and encodes the image_gen_id, workspace_id, and an expiry timestamp.
This ensures only users with access to the workspace can view images,
without requiring auth headers (which <img> tags cannot pass).
Args:

View file

@ -83,7 +83,7 @@ async def connect_jira(
Initiate Jira OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -326,7 +326,7 @@ async def jira_callback(
sa_select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)
@ -392,7 +392,7 @@ async def jira_callback(
connector_type=SearchSourceConnectorType.JIRA_CONNECTOR,
is_indexable=False,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@ -454,7 +454,7 @@ async def reauth_jira(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)

View file

@ -87,7 +87,7 @@ async def connect_linear(
Initiate Linear OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -148,7 +148,7 @@ async def reauth_linear(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
@ -346,7 +346,7 @@ async def linear_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
@ -406,7 +406,7 @@ async def linear_callback(
connector_type=SearchSourceConnectorType.LINEAR_CONNECTOR,
is_indexable=False,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)

View file

@ -11,8 +11,8 @@ from app.db import (
LogLevel,
LogStatus,
Permission,
SearchSpace,
SearchSpaceMembership,
Workspace,
WorkspaceMembership,
get_async_session,
)
from app.schemas import LogCreate, LogRead, LogUpdate
@ -33,13 +33,13 @@ async def create_log(
Note: This is typically called internally. Requires LOGS_READ permission (since logs are usually system-generated).
"""
try:
# Check if the user has access to the search space
# Check if the user has access to the workspace
await check_permission(
session,
auth,
log.search_space_id,
log.workspace_id,
Permission.LOGS_READ.value,
"You don't have permission to access logs in this search space",
"You don't have permission to access logs in this workspace",
)
db_log = Log(**log.model_dump())
@ -60,7 +60,7 @@ async def create_log(
async def read_logs(
skip: int = 0,
limit: int = 100,
search_space_id: int | None = None,
workspace_id: int | None = None,
level: LogLevel | None = None,
status: LogStatus | None = None,
source: str | None = None,
@ -72,34 +72,34 @@ async def read_logs(
user = auth.user
"""
Get logs with optional filtering.
Requires LOGS_READ permission for the search space(s).
Requires LOGS_READ permission for the workspace(s).
"""
try:
# Apply filters
filters = []
if search_space_id is not None:
# Check permission for specific search space
if workspace_id is not None:
# Check permission for specific workspace
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.LOGS_READ.value,
"You don't have permission to read logs in this search space",
"You don't have permission to read logs in this workspace",
)
# Build query for specific search space
# Build query for specific workspace
query = (
select(Log)
.filter(Log.search_space_id == search_space_id)
.filter(Log.workspace_id == workspace_id)
.order_by(desc(Log.created_at))
)
else:
# Build base query - logs from search spaces user has membership in
# Build base query - logs from workspaces user has membership in
query = (
select(Log)
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
.order_by(desc(Log.created_at))
)
@ -141,7 +141,7 @@ async def read_log(
):
"""
Get a specific log by ID.
Requires LOGS_READ permission for the search space.
Requires LOGS_READ permission for the workspace.
"""
try:
result = await session.execute(select(Log).filter(Log.id == log_id))
@ -150,13 +150,13 @@ async def read_log(
if not log:
raise HTTPException(status_code=404, detail="Log not found")
# Check permission for the search space
# Check permission for the workspace
await check_permission(
session,
auth,
log.search_space_id,
log.workspace_id,
Permission.LOGS_READ.value,
"You don't have permission to read logs in this search space",
"You don't have permission to read logs in this workspace",
)
return log
@ -186,13 +186,13 @@ async def update_log(
if not db_log:
raise HTTPException(status_code=404, detail="Log not found")
# Check permission for the search space
# Check permission for the workspace
await check_permission(
session,
auth,
db_log.search_space_id,
db_log.workspace_id,
Permission.LOGS_READ.value,
"You don't have permission to access logs in this search space",
"You don't have permission to access logs in this workspace",
)
# Update only provided fields
@ -220,7 +220,7 @@ async def delete_log(
):
"""
Delete a log entry.
Requires LOGS_DELETE permission for the search space.
Requires LOGS_DELETE permission for the workspace.
"""
try:
result = await session.execute(select(Log).filter(Log.id == log_id))
@ -229,13 +229,13 @@ async def delete_log(
if not db_log:
raise HTTPException(status_code=404, detail="Log not found")
# Check permission for the search space
# Check permission for the workspace
await check_permission(
session,
auth,
db_log.search_space_id,
db_log.workspace_id,
Permission.LOGS_DELETE.value,
"You don't have permission to delete logs in this search space",
"You don't have permission to delete logs in this workspace",
)
await session.delete(db_log)
@ -250,25 +250,25 @@ async def delete_log(
) from e
@router.get("/logs/search-space/{search_space_id}/summary")
@router.get("/logs/workspaces/{workspace_id}/summary")
async def get_logs_summary(
search_space_id: int,
workspace_id: int,
hours: int = 24,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
Get a summary of logs for a search space in the last X hours.
Requires LOGS_READ permission for the search space.
Get a summary of logs for a workspace in the last X hours.
Requires LOGS_READ permission for the workspace.
"""
try:
# Check permission
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.LOGS_READ.value,
"You don't have permission to read logs in this search space",
"You don't have permission to read logs in this workspace",
)
# Calculate time window
@ -278,7 +278,7 @@ async def get_logs_summary(
result = await session.execute(
select(Log)
.filter(
and_(Log.search_space_id == search_space_id, Log.created_at >= since)
and_(Log.workspace_id == workspace_id, Log.created_at >= since)
)
.order_by(desc(Log.created_at))
)

View file

@ -23,7 +23,7 @@ class AddLumaConnectorRequest(BaseModel):
"""Request model for adding a Luma connector."""
api_key: str = Field(..., description="Luma API key")
space_id: int = Field(..., description="Search space ID")
space_id: int = Field(..., description="Workspace ID")
@router.post("/connectors/luma/add")
@ -48,10 +48,10 @@ async def add_luma_connector(
"""
user = auth.user
try:
# Check if a Luma connector already exists for this search space and user
# Check if a Luma connector already exists for this workspace and user
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == request.space_id,
SearchSourceConnector.workspace_id == request.space_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
@ -81,7 +81,7 @@ async def add_luma_connector(
name="Luma Event Connector",
connector_type=SearchSourceConnectorType.LUMA_CONNECTOR,
config={"api_key": request.api_key},
search_space_id=request.space_id,
workspace_id=request.space_id,
user_id=user.id,
is_indexable=False,
)
@ -123,10 +123,10 @@ async def delete_luma_connector(
session: AsyncSession = Depends(get_async_session),
):
"""
Delete the Luma connector for the authenticated user in a specific search space.
Delete the Luma connector for the authenticated user in a specific workspace.
Args:
space_id: Search space ID
space_id: Workspace ID
user: Current authenticated user
session: Database session
@ -140,7 +140,7 @@ async def delete_luma_connector(
try:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
@ -179,10 +179,10 @@ async def test_luma_connector(
session: AsyncSession = Depends(get_async_session),
):
"""
Test the Luma connector for the authenticated user in a specific search space.
Test the Luma connector for the authenticated user in a specific workspace.
Args:
space_id: Search space ID
space_id: Workspace ID
user: Current authenticated user
session: Database session
@ -194,10 +194,10 @@ async def test_luma_connector(
"""
user = auth.user
try:
# Get the Luma connector for this search space and user
# Get the Luma connector for this workspace and user
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,

View file

@ -413,7 +413,7 @@ async def mcp_oauth_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type == db_connector_type,
)
)
@ -468,7 +468,7 @@ async def mcp_oauth_callback(
connector_type=db_connector_type,
is_indexable=False,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@ -539,7 +539,7 @@ async def reauth_mcp_service(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type == db_connector_type,
)
)

View file

@ -14,7 +14,7 @@ from app.db import (
ModelSource,
NewChatThread,
Permission,
SearchSpace,
Workspace,
get_async_session,
)
from app.schemas import (
@ -88,7 +88,7 @@ def _connection_read(
api_key=conn.api_key,
extra=conn.extra or {},
scope=conn.scope,
search_space_id=conn.search_space_id,
workspace_id=conn.workspace_id,
user_id=conn.user_id,
enabled=conn.enabled,
has_api_key=bool(conn.api_key),
@ -142,7 +142,7 @@ def _default_model_for(models: list[Model], capability: str) -> int | None:
async def _load_role_model(
session: AsyncSession,
search_space_id: int,
workspace_id: int,
model_id: int,
) -> Model | dict | None:
if model_id < 0:
@ -157,7 +157,7 @@ async def _load_role_model(
.where(Model.id == model_id)
)
model = result.scalars().first()
if model is None or model.connection.search_space_id != search_space_id:
if model is None or model.connection.workspace_id != workspace_id:
return None
return model
@ -171,14 +171,14 @@ def _role_model_enabled(model: Model | dict) -> bool:
async def _validate_role_model_id(
session: AsyncSession,
*,
search_space_id: int,
workspace_id: int,
model_id: int | None,
capability: str,
) -> int:
if model_id is None or model_id == 0:
return 0
model = await _load_role_model(session, search_space_id, model_id)
model = await _load_role_model(session, workspace_id, model_id)
if model and _role_model_enabled(model) and has_capability(model, capability):
return model_id
@ -191,14 +191,14 @@ async def _validate_role_model_id(
async def _resolve_role_model_id(
session: AsyncSession,
*,
search_space_id: int,
workspace_id: int,
model_id: int | None,
capability: str,
) -> int:
try:
return await _validate_role_model_id(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
model_id=model_id,
capability=capability,
)
@ -207,28 +207,28 @@ async def _resolve_role_model_id(
async def _clear_invalid_roles(
session: AsyncSession, search_space_id: int
) -> SearchSpace:
search_space = await _get_search_space(session, search_space_id)
search_space.chat_model_id = await _resolve_role_model_id(
session: AsyncSession, workspace_id: int
) -> Workspace:
workspace = await _get_workspace(session, workspace_id)
workspace.chat_model_id = await _resolve_role_model_id(
session,
search_space_id=search_space_id,
model_id=search_space.chat_model_id,
workspace_id=workspace_id,
model_id=workspace.chat_model_id,
capability="chat",
)
search_space.vision_model_id = await _resolve_role_model_id(
workspace.vision_model_id = await _resolve_role_model_id(
session,
search_space_id=search_space_id,
model_id=search_space.vision_model_id,
workspace_id=workspace_id,
model_id=workspace.vision_model_id,
capability="vision",
)
search_space.image_gen_model_id = await _resolve_role_model_id(
workspace.image_gen_model_id = await _resolve_role_model_id(
session,
search_space_id=search_space_id,
model_id=search_space.image_gen_model_id,
workspace_id=workspace_id,
model_id=workspace.image_gen_model_id,
capability="image_gen",
)
return search_space
return workspace
async def _default_unset_roles(
@ -236,24 +236,24 @@ async def _default_unset_roles(
conn: Connection,
models: list[Model],
) -> None:
if conn.scope != ConnectionScope.SEARCH_SPACE or conn.search_space_id is None:
if conn.scope != ConnectionScope.SEARCH_SPACE or conn.workspace_id is None:
return
search_space = await _get_search_space(session, conn.search_space_id)
if search_space.chat_model_id is None:
search_space.chat_model_id = _default_model_for(models, "chat")
if search_space.vision_model_id is None:
workspace = await _get_workspace(session, conn.workspace_id)
if workspace.chat_model_id is None:
workspace.chat_model_id = _default_model_for(models, "chat")
if workspace.vision_model_id is None:
vision_default = None
if search_space.chat_model_id:
if workspace.chat_model_id:
chat_model = next(
(m for m in models if m.id == search_space.chat_model_id), None
(m for m in models if m.id == workspace.chat_model_id), None
)
if chat_model and has_capability(chat_model, "vision"):
vision_default = chat_model.id
search_space.vision_model_id = vision_default or _default_model_for(
workspace.vision_model_id = vision_default or _default_model_for(
models, "vision"
)
if search_space.image_gen_model_id is None:
search_space.image_gen_model_id = _default_model_for(models, "image_gen")
if workspace.image_gen_model_id is None:
workspace.image_gen_model_id = _default_model_for(models, "image_gen")
@router.get("/model-providers", response_model=list[ModelProviderRead])
@ -274,14 +274,14 @@ async def list_model_providers(auth: AuthContext = Depends(require_session_conte
]
async def _get_search_space(session: AsyncSession, search_space_id: int) -> SearchSpace:
async def _get_workspace(session: AsyncSession, workspace_id: int) -> Workspace:
result = await session.execute(
select(SearchSpace).where(SearchSpace.id == search_space_id)
select(Workspace).where(Workspace.id == workspace_id)
)
search_space = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
return search_space
workspace = result.scalars().first()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
async def _load_connection(session: AsyncSession, connection_id: int) -> Connection:
@ -304,13 +304,13 @@ async def _assert_connection_access(
allow_spaceless_pat: bool = False,
) -> None:
user = auth.user
if conn.search_space_id:
if conn.workspace_id:
await check_permission(
session,
auth,
conn.search_space_id,
conn.workspace_id,
permission,
"You don't have permission to manage model connections in this search space",
"You don't have permission to manage model connections in this workspace",
)
return
if conn.user_id != user.id:
@ -346,21 +346,21 @@ async def list_global_connections(auth: AuthContext = Depends(require_session_co
@router.get("/model-connections", response_model=list[ConnectionRead])
async def list_connections(
search_space_id: int | None = None,
workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
stmt = select(Connection).options(selectinload(Connection.models))
if search_space_id is not None:
if workspace_id is not None:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to view model connections in this search space",
"You don't have permission to view model connections in this workspace",
)
stmt = stmt.where(Connection.search_space_id == search_space_id)
stmt = stmt.where(Connection.workspace_id == workspace_id)
else:
stmt = stmt.where(Connection.user_id == user.id)
result = await session.execute(stmt.order_by(Connection.id))
@ -379,25 +379,25 @@ async def create_connection(
if data.scope == ConnectionScope.GLOBAL:
raise HTTPException(status_code=400, detail="GLOBAL connections are YAML-only")
if data.scope == ConnectionScope.SEARCH_SPACE:
if data.search_space_id is None:
raise HTTPException(status_code=400, detail="search_space_id is required")
if data.workspace_id is None:
raise HTTPException(status_code=400, detail="workspace_id is required")
await check_permission(
session,
auth,
data.search_space_id,
data.workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to create model connections in this search space",
"You don't have permission to create model connections in this workspace",
)
elif auth.is_gated:
raise HTTPException(
status_code=403,
detail="Managing personal model connections requires an interactive session",
)
payload = data.model_dump(exclude={"search_space_id", "models"})
payload = data.model_dump(exclude={"workspace_id", "models"})
conn = Connection(
**payload,
search_space_id=data.search_space_id
workspace_id=data.workspace_id
if data.scope == ConnectionScope.SEARCH_SPACE
else None,
user_id=user.id,
@ -430,13 +430,13 @@ async def preview_connection_models(
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
if data.scope == ConnectionScope.SEARCH_SPACE and data.search_space_id is not None:
if data.scope == ConnectionScope.SEARCH_SPACE and data.workspace_id is not None:
await check_permission(
session,
auth,
data.search_space_id,
data.workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to create model connections in this search space",
"You don't have permission to create model connections in this workspace",
)
elif auth.is_gated:
raise HTTPException(
@ -451,7 +451,7 @@ async def preview_connection_models(
extra=data.extra or {},
scope=data.scope,
enabled=data.enabled,
search_space_id=data.search_space_id
workspace_id=data.workspace_id
if data.scope == ConnectionScope.SEARCH_SPACE
else None,
user_id=user.id,
@ -470,13 +470,13 @@ async def test_preview_connection_model(
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
if data.scope == ConnectionScope.SEARCH_SPACE and data.search_space_id is not None:
if data.scope == ConnectionScope.SEARCH_SPACE and data.workspace_id is not None:
await check_permission(
session,
auth,
data.search_space_id,
data.workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to create model connections in this search space",
"You don't have permission to create model connections in this workspace",
)
elif auth.is_gated:
raise HTTPException(
@ -495,7 +495,7 @@ async def test_preview_connection_model(
extra=data.extra or {},
scope=data.scope,
enabled=data.enabled,
search_space_id=data.search_space_id
workspace_id=data.workspace_id
if data.scope == ConnectionScope.SEARCH_SPACE
else None,
user_id=user.id,
@ -525,12 +525,12 @@ async def update_connection(
await _assert_connection_access(
session, auth, conn, Permission.LLM_CONFIGS_UPDATE.value
)
search_space_id = conn.search_space_id
workspace_id = conn.workspace_id
for key, value in data.model_dump(exclude_unset=True).items():
setattr(conn, key, value)
await session.commit()
if search_space_id is not None:
await _clear_invalid_roles(session, search_space_id)
if workspace_id is not None:
await _clear_invalid_roles(session, workspace_id)
await session.commit()
conn = await _load_connection(session, connection_id)
return _connection_read(conn, list(conn.models))
@ -546,11 +546,11 @@ async def delete_connection(
await _assert_connection_access(
session, auth, conn, Permission.LLM_CONFIGS_DELETE.value
)
search_space_id = conn.search_space_id
workspace_id = conn.workspace_id
await session.delete(conn)
await session.commit()
if search_space_id is not None:
await _clear_invalid_roles(session, search_space_id)
if workspace_id is not None:
await _clear_invalid_roles(session, workspace_id)
await session.commit()
return {"status": "deleted"}
@ -611,8 +611,8 @@ async def discover_connection_models(
await session.commit()
conn = await _load_connection(session, connection_id)
await _default_unset_roles(session, conn, list(conn.models))
if conn.search_space_id is not None:
await _clear_invalid_roles(session, conn.search_space_id)
if conn.workspace_id is not None:
await _clear_invalid_roles(session, conn.workspace_id)
await session.commit()
conn = await _load_connection(session, connection_id)
return [_model_read(model) for model in conn.models]
@ -654,8 +654,8 @@ async def add_manual_model(
await session.refresh(model)
conn = await _load_connection(session, connection_id)
await _default_unset_roles(session, conn, list(conn.models))
if conn.search_space_id is not None:
await _clear_invalid_roles(session, conn.search_space_id)
if conn.workspace_id is not None:
await _clear_invalid_roles(session, conn.workspace_id)
await session.commit()
await session.refresh(model)
return _model_read(model)
@ -674,7 +674,7 @@ async def bulk_update_models(
await _assert_connection_access(
session, auth, conn, Permission.LLM_CONFIGS_UPDATE.value
)
search_space_id = conn.search_space_id
workspace_id = conn.workspace_id
model_ids = set(data.model_ids)
await session.execute(
@ -684,8 +684,8 @@ async def bulk_update_models(
)
await session.commit()
session.expire_all()
if search_space_id is not None:
await _clear_invalid_roles(session, search_space_id)
if workspace_id is not None:
await _clear_invalid_roles(session, workspace_id)
await session.commit()
session.expire_all()
@ -715,14 +715,14 @@ async def update_model(
await _assert_connection_access(
session, auth, model.connection, Permission.LLM_CONFIGS_UPDATE.value
)
search_space_id = model.connection.search_space_id
workspace_id = model.connection.workspace_id
update = data.model_dump(exclude_unset=True)
for key, value in update.items():
setattr(model, key, value)
await session.commit()
await session.refresh(model)
if search_space_id is not None:
await _clear_invalid_roles(session, search_space_id)
if workspace_id is not None:
await _clear_invalid_roles(session, workspace_id)
await session.commit()
await session.refresh(model)
return _model_read(model)
@ -753,35 +753,35 @@ async def test_connection_model(
@router.get(
"/search-spaces/{search_space_id}/model-roles", response_model=ModelRolesRead
"/workspaces/{workspace_id}/model-roles", response_model=ModelRolesRead
)
async def get_model_roles(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to view model roles in this search space",
"You don't have permission to view model roles in this workspace",
)
search_space = await _clear_invalid_roles(session, search_space_id)
workspace = await _clear_invalid_roles(session, workspace_id)
await session.commit()
await session.refresh(search_space)
await session.refresh(workspace)
return ModelRolesRead(
chat_model_id=search_space.chat_model_id,
vision_model_id=search_space.vision_model_id,
image_gen_model_id=search_space.image_gen_model_id,
chat_model_id=workspace.chat_model_id,
vision_model_id=workspace.vision_model_id,
image_gen_model_id=workspace.image_gen_model_id,
)
@router.put(
"/search-spaces/{search_space_id}/model-roles", response_model=ModelRolesRead
"/workspaces/{workspace_id}/model-roles", response_model=ModelRolesRead
)
async def update_model_roles(
search_space_id: int,
workspace_id: int,
data: ModelRolesUpdate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -789,51 +789,51 @@ async def update_model_roles(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.LLM_CONFIGS_UPDATE.value,
"You don't have permission to update model roles in this search space",
"You don't have permission to update model roles in this workspace",
)
search_space = await _get_search_space(session, search_space_id)
workspace = await _get_workspace(session, workspace_id)
updates = data.model_dump(exclude_unset=True)
if "chat_model_id" in updates:
previous_chat_model_id = search_space.chat_model_id
previous_chat_model_id = workspace.chat_model_id
next_chat_model_id = await _validate_role_model_id(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
model_id=updates["chat_model_id"],
capability="chat",
)
search_space.chat_model_id = next_chat_model_id
workspace.chat_model_id = next_chat_model_id
if next_chat_model_id != previous_chat_model_id:
await session.execute(
update(NewChatThread)
.where(NewChatThread.search_space_id == search_space_id)
.where(NewChatThread.workspace_id == workspace_id)
.values(pinned_llm_config_id=None)
)
logger.info(
"Cleared auto model pins for search_space_id=%s after chat_model_id change (%s -> %s)",
search_space_id,
"Cleared auto model pins for workspace_id=%s after chat_model_id change (%s -> %s)",
workspace_id,
previous_chat_model_id,
next_chat_model_id,
)
if "vision_model_id" in updates:
search_space.vision_model_id = await _validate_role_model_id(
workspace.vision_model_id = await _validate_role_model_id(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
model_id=updates["vision_model_id"],
capability="vision",
)
if "image_gen_model_id" in updates:
search_space.image_gen_model_id = await _validate_role_model_id(
workspace.image_gen_model_id = await _validate_role_model_id(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
model_id=updates["image_gen_model_id"],
capability="image_gen",
)
await session.commit()
await session.refresh(search_space)
await session.refresh(workspace)
return ModelRolesRead(
chat_model_id=search_space.chat_model_id,
vision_model_id=search_space.vision_model_id,
image_gen_model_id=search_space.image_gen_model_id,
chat_model_id=workspace.chat_model_id,
vision_model_id=workspace.vision_model_id,
image_gen_model_id=workspace.image_gen_model_id,
)

View file

@ -45,7 +45,7 @@ from app.db import (
NewChatMessageRole,
NewChatThread,
Permission,
SearchSpace,
Workspace,
TokenUsage,
User,
get_async_session,
@ -525,7 +525,7 @@ async def check_thread_access(
Access is granted if:
- User is the creator of the thread
- Thread visibility is SEARCH_SPACE (any member can access) - for read/update operations only
- Thread is a legacy thread (created_by_id is NULL) - only if user is search space owner
- Thread is a legacy thread (created_by_id is NULL) - only if user is workspace owner
Args:
session: Database session
@ -558,16 +558,16 @@ async def check_thread_access(
return True
# For legacy threads (created before visibility feature),
# only the search space owner can access
# only the workspace owner can access
if is_legacy:
search_space_query = select(SearchSpace).filter(
SearchSpace.id == thread.search_space_id
workspace_query = select(Workspace).filter(
Workspace.id == thread.workspace_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
workspace_result = await session.execute(workspace_query)
workspace = workspace_result.scalar_one_or_none()
is_workspace_owner = workspace and workspace.user_id == user.id
if is_search_space_owner:
if is_workspace_owner:
return True
# Legacy threads are not accessible to non-owners
raise HTTPException(
@ -593,23 +593,23 @@ async def check_thread_access(
@router.get("/threads", response_model=ThreadListResponse)
async def list_threads(
search_space_id: int,
workspace_id: int,
limit: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
List all accessible threads for the current user in a search space.
List all accessible threads for the current user in a workspace.
Returns threads and archived_threads for ThreadListPrimitive.
A user can see threads that are:
- Created by them (regardless of visibility)
- Shared with the search space (visibility = SEARCH_SPACE)
- Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
- Shared with the workspace (visibility = SEARCH_SPACE)
- Legacy threads with no creator (created_by_id is NULL) - only if user is workspace owner
Args:
search_space_id: The search space to list threads for
workspace_id: The workspace to list threads for
limit: Optional limit on number of threads to return (applies to active threads only)
Requires CHATS_READ permission.
@ -618,36 +618,36 @@ async def list_threads(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
"You don't have permission to read chats in this workspace",
)
# Check if user is the search space owner (for legacy thread visibility)
search_space_query = select(SearchSpace).filter(
SearchSpace.id == search_space_id
# Check if user is the workspace owner (for legacy thread visibility)
workspace_query = select(Workspace).filter(
Workspace.id == workspace_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
workspace_result = await session.execute(workspace_query)
workspace = workspace_result.scalar_one_or_none()
is_workspace_owner = workspace and workspace.user_id == user.id
# Build filter conditions:
# 1. Created by the current user (any visibility)
# 2. Shared with the search space (visibility = SEARCH_SPACE)
# 3. Legacy threads (created_by_id is NULL) - only visible to search space owner
# 2. Shared with the workspace (visibility = SEARCH_SPACE)
# 3. Legacy threads (created_by_id is NULL) - only visible to workspace owner
filter_conditions = [
NewChatThread.created_by_id == user.id,
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
]
# Only include legacy threads for the search space owner
if is_search_space_owner:
# Only include legacy threads for the workspace owner
if is_workspace_owner:
filter_conditions.append(NewChatThread.created_by_id.is_(None))
query = (
select(NewChatThread)
.filter(
NewChatThread.search_space_id == search_space_id,
NewChatThread.workspace_id == workspace_id,
or_(*filter_conditions),
)
.order_by(NewChatThread.updated_at.desc())
@ -663,7 +663,7 @@ async def list_threads(
for thread in all_threads:
# Legacy threads (no creator) are treated as own threads for owner
is_own_thread = thread.created_by_id == user.id or (
thread.created_by_id is None and is_search_space_owner
thread.created_by_id is None and is_workspace_owner
)
item = ThreadListItem(
id=thread.id,
@ -701,22 +701,22 @@ async def list_threads(
@router.get("/threads/search", response_model=list[ThreadListItem])
async def search_threads(
search_space_id: int,
workspace_id: int,
title: str,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Search accessible threads by title in a search space.
Search accessible threads by title in a workspace.
A user can search threads that are:
- Created by them (regardless of visibility)
- Shared with the search space (visibility = SEARCH_SPACE)
- Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
- Shared with the workspace (visibility = SEARCH_SPACE)
- Legacy threads with no creator (created_by_id is NULL) - only if user is workspace owner
Args:
search_space_id: The search space to search in
workspace_id: The workspace to search in
title: The search query (case-insensitive partial match)
Requires CHATS_READ permission.
@ -725,18 +725,18 @@ async def search_threads(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
"You don't have permission to read chats in this workspace",
)
# Check if user is the search space owner (for legacy thread visibility)
search_space_query = select(SearchSpace).filter(
SearchSpace.id == search_space_id
# Check if user is the workspace owner (for legacy thread visibility)
workspace_query = select(Workspace).filter(
Workspace.id == workspace_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
workspace_result = await session.execute(workspace_query)
workspace = workspace_result.scalar_one_or_none()
is_workspace_owner = workspace and workspace.user_id == user.id
# Build filter conditions
filter_conditions = [
@ -744,15 +744,15 @@ async def search_threads(
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
]
# Only include legacy threads for the search space owner
if is_search_space_owner:
# Only include legacy threads for the workspace owner
if is_workspace_owner:
filter_conditions.append(NewChatThread.created_by_id.is_(None))
# Search accessible threads by title (case-insensitive)
query = (
select(NewChatThread)
.filter(
NewChatThread.search_space_id == search_space_id,
NewChatThread.workspace_id == workspace_id,
NewChatThread.title.ilike(f"%{title}%"),
or_(*filter_conditions),
)
@ -772,7 +772,7 @@ async def search_threads(
# Legacy threads (no creator) are treated as own threads for owner
is_own_thread=(
thread.created_by_id == user.id
or (thread.created_by_id is None and is_search_space_owner)
or (thread.created_by_id is None and is_workspace_owner)
),
created_at=thread.created_at,
updated_at=thread.updated_at,
@ -812,9 +812,9 @@ async def create_thread(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_CREATE.value,
"You don't have permission to create chats in this search space",
"You don't have permission to create chats in this workspace",
)
now = datetime.now(UTC)
@ -822,7 +822,7 @@ async def create_thread(
title=thread.title,
archived=thread.archived,
visibility=thread.visibility,
search_space_id=thread.search_space_id,
workspace_id=thread.workspace_id,
created_by_id=user.id,
updated_at=now,
)
@ -879,13 +879,13 @@ async def get_thread_messages(
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
# Check permission to read chats in this search space
# Check permission to read chats in this workspace
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
"You don't have permission to read chats in this workspace",
)
# Check thread-level access based on visibility
@ -971,9 +971,9 @@ async def get_thread_full(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
"You don't have permission to read chats in this workspace",
)
# Check thread-level access based on visibility
@ -1035,9 +1035,9 @@ async def update_thread(
await check_permission(
session,
auth,
db_thread.search_space_id,
db_thread.workspace_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
"You don't have permission to update chats in this workspace",
)
# For PRIVATE threads, only the creator can update
@ -1104,16 +1104,16 @@ async def delete_thread(
await check_permission(
session,
auth,
db_thread.search_space_id,
db_thread.workspace_id,
Permission.CHATS_DELETE.value,
"You don't have permission to delete chats in this search space",
"You don't have permission to delete chats in this workspace",
)
# For PRIVATE threads, only the creator can delete
# For SEARCH_SPACE threads, any member with permission can delete
# Legacy threads (created_by_id is NULL) have no recorded creator,
# so we skip strict ownership and fall through to legacy handling
# which allows the search space owner to delete them
# which allows the workspace owner to delete them
if db_thread.visibility == ChatVisibility.PRIVATE:
await check_thread_access(
session,
@ -1162,7 +1162,7 @@ async def update_thread_visibility(
Only the creator of the thread can change its visibility.
- PRIVATE: Only the creator can access the thread (default)
- SEARCH_SPACE: All members of the search space can access the thread
- SEARCH_SPACE: All members of the workspace can access the thread
Requires CHATS_UPDATE permission.
"""
@ -1178,9 +1178,9 @@ async def update_thread_visibility(
await check_permission(
session,
auth,
db_thread.search_space_id,
db_thread.workspace_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
"You don't have permission to update chats in this workspace",
)
# Only the creator can change visibility
@ -1381,9 +1381,9 @@ async def append_message(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
"You don't have permission to update chats in this workspace",
)
# Check thread-level access based on visibility
@ -1552,7 +1552,7 @@ async def append_message(
call_details=token_usage_data.get("call_details"),
thread_id=thread_id,
message_id=db_message.id,
search_space_id=thread.search_space_id,
workspace_id=thread.workspace_id,
user_id=user_uuid,
)
.on_conflict_do_nothing(
@ -1632,9 +1632,9 @@ async def list_messages(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
"You don't have permission to read chats in this workspace",
)
# Check thread-level access based on visibility
@ -1730,9 +1730,9 @@ async def handle_new_chat(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_CREATE.value,
"You don't have permission to chat in this search space",
"You don't have permission to chat in this workspace",
)
# Check thread-level access based on visibility
@ -1744,24 +1744,24 @@ async def handle_new_chat(
local_mounts=request.local_filesystem_mounts,
)
# Get search space to check LLM config preferences
search_space_result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
# Get workspace to check LLM config preferences
workspace_result = await session.execute(
select(Workspace).filter(Workspace.id == request.workspace_id)
)
search_space = search_space_result.scalars().first()
workspace = workspace_result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
# Use the converged model-connections role for chat operations.
# Positive IDs load Model + Connection rows; negative IDs load
# virtual GLOBAL models; 0 means Auto.
llm_config_id = (
search_space.chat_model_id if search_space.chat_model_id is not None else 0
workspace.chat_model_id if workspace.chat_model_id is not None else 0
)
# Release the read-transaction so we don't hold ACCESS SHARE locks
# on searchspaces/documents for the entire duration of the stream.
# on workspaces/documents for the entire duration of the stream.
# expire_on_commit=False keeps loaded ORM attrs usable.
await session.commit()
# Close the dependency session now so its connection returns to
@ -1791,7 +1791,7 @@ async def handle_new_chat(
return StreamingResponse(
stream_new_chat(
user_query=request.user_query,
search_space_id=request.search_space_id,
workspace_id=request.workspace_id,
chat_id=request.chat_id,
user_id=str(user.id),
llm_config_id=llm_config_id,
@ -1849,9 +1849,9 @@ async def cancel_active_turn(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
"You don't have permission to update chats in this workspace",
)
await check_thread_access(session, thread, user)
@ -1901,9 +1901,9 @@ async def get_turn_status(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to view chats in this search space",
"You don't have permission to view chats in this workspace",
)
await check_thread_access(session, thread, user)
@ -1965,9 +1965,9 @@ async def regenerate_response(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
"You don't have permission to update chats in this workspace",
)
# Check thread-level access based on visibility
@ -2234,21 +2234,21 @@ async def regenerate_response(
seen_turns.add(tid)
revert_turn_ids.append(tid)
# Get search space for LLM config
search_space_result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
# Get workspace for LLM config
workspace_result = await session.execute(
select(Workspace).filter(Workspace.id == request.workspace_id)
)
search_space = search_space_result.scalars().first()
workspace = workspace_result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
llm_config_id = (
search_space.chat_model_id if search_space.chat_model_id is not None else 0
workspace.chat_model_id if workspace.chat_model_id is not None else 0
)
# Release the read-transaction so we don't hold ACCESS SHARE locks
# on searchspaces/documents for the entire duration of the stream.
# on workspaces/documents for the entire duration of the stream.
# expire_on_commit=False keeps loaded ORM attrs (including messages_to_delete PKs) usable.
await session.commit()
await session.close()
@ -2288,7 +2288,7 @@ async def regenerate_response(
try:
async for chunk in stream_new_chat(
user_query=str(user_query_to_use),
search_space_id=request.search_space_id,
workspace_id=request.workspace_id,
chat_id=thread_id,
user_id=str(user.id),
llm_config_id=llm_config_id,
@ -2390,9 +2390,9 @@ async def resume_chat(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_CREATE.value,
"You don't have permission to chat in this search space",
"You don't have permission to chat in this workspace",
)
await check_thread_access(session, thread, user)
@ -2403,29 +2403,29 @@ async def resume_chat(
local_mounts=request.local_filesystem_mounts,
)
search_space_result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
workspace_result = await session.execute(
select(Workspace).filter(Workspace.id == request.workspace_id)
)
search_space = search_space_result.scalars().first()
workspace = workspace_result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
llm_config_id = (
search_space.chat_model_id if search_space.chat_model_id is not None else 0
workspace.chat_model_id if workspace.chat_model_id is not None else 0
)
decisions = [d.model_dump() for d in request.decisions]
# Release the read-transaction so we don't hold ACCESS SHARE locks
# on searchspaces/documents for the entire duration of the stream.
# on workspaces/documents for the entire duration of the stream.
await session.commit()
await session.close()
return StreamingResponse(
stream_resume_chat(
chat_id=thread_id,
search_space_id=request.search_space_id,
workspace_id=request.workspace_id,
decisions=decisions,
user_id=str(user.id),
llm_config_id=llm_config_id,

View file

@ -23,9 +23,9 @@ class CreateNoteRequest(BaseModel):
source_markdown: str | None = None
@router.post("/search-spaces/{search_space_id}/notes", response_model=DocumentRead)
@router.post("/workspaces/{workspace_id}/notes", response_model=DocumentRead)
async def create_note(
search_space_id: int,
workspace_id: int,
request: CreateNoteRequest,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -40,9 +40,9 @@ async def create_note(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create notes in this search space",
"You don't have permission to create notes in this workspace",
)
if not request.title or not request.title.strip():
@ -58,7 +58,7 @@ async def create_note(
# Create document with NOTE type
document = Document(
search_space_id=search_space_id,
workspace_id=workspace_id,
title=request.title.strip(),
document_type=DocumentType.NOTE,
content="", # Empty initially, will be populated on first save/reindex
@ -83,7 +83,7 @@ async def create_note(
content_hash=document.content_hash,
unique_identifier_hash=document.unique_identifier_hash,
document_metadata=document.document_metadata,
search_space_id=document.search_space_id,
workspace_id=document.workspace_id,
created_at=document.created_at,
updated_at=document.updated_at,
created_by_id=document.created_by_id,
@ -91,11 +91,11 @@ async def create_note(
@router.get(
"/search-spaces/{search_space_id}/notes",
"/workspaces/{workspace_id}/notes",
response_model=PaginatedResponse[DocumentRead],
)
async def list_notes(
search_space_id: int,
workspace_id: int,
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
@ -103,7 +103,7 @@ async def list_notes(
auth: AuthContext = Depends(get_auth_context),
):
"""
List all notes in a search space.
List all notes in a workspace.
Requires DOCUMENTS_READ permission.
"""
@ -111,16 +111,16 @@ async def list_notes(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read notes in this search space",
"You don't have permission to read notes in this workspace",
)
from sqlalchemy import func
# Build query
query = select(Document).where(
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
Document.document_type == DocumentType.NOTE,
)
@ -128,7 +128,7 @@ async def list_notes(
count_query = select(func.count()).select_from(
select(Document)
.where(
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
Document.document_type == DocumentType.NOTE,
)
.subquery()
@ -164,7 +164,7 @@ async def list_notes(
content_hash=doc.content_hash,
unique_identifier_hash=doc.unique_identifier_hash,
document_metadata=doc.document_metadata,
search_space_id=doc.search_space_id,
workspace_id=doc.workspace_id,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
@ -188,9 +188,9 @@ async def list_notes(
)
@router.delete("/search-spaces/{search_space_id}/notes/{note_id}")
@router.delete("/workspaces/{workspace_id}/notes/{note_id}")
async def delete_note(
search_space_id: int,
workspace_id: int,
note_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -204,16 +204,16 @@ async def delete_note(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete notes in this search space",
"You don't have permission to delete notes in this workspace",
)
# Get document
result = await session.execute(
select(Document).where(
Document.id == note_id,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
Document.document_type == DocumentType.NOTE,
)
)

View file

@ -84,7 +84,7 @@ async def connect_notion(
Initiate Notion OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -145,7 +145,7 @@ async def reauth_notion(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.NOTION_CONNECTOR,
)
@ -345,7 +345,7 @@ async def notion_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.NOTION_CONNECTOR,
)
@ -408,7 +408,7 @@ async def notion_callback(
connector_type=SearchSourceConnectorType.NOTION_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)

View file

@ -415,7 +415,7 @@ class OAuthConnectorRoute:
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type == oauth.connector_type,
)
)
@ -530,7 +530,7 @@ class OAuthConnectorRoute:
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type == oauth.connector_type,
)
)
@ -597,7 +597,7 @@ class OAuthConnectorRoute:
connector_type=oauth.connector_type,
is_indexable=oauth.is_indexable,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)

View file

@ -22,7 +22,7 @@ from app.db import (
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
SearchSpace,
Workspace,
User,
get_async_session,
)
@ -54,7 +54,7 @@ from app.services.obsidian_plugin_indexer import (
)
from app.tasks.celery_tasks.obsidian_tasks import index_obsidian_attachment_task
from app.users import allow_any_principal, get_auth_context
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@ -102,7 +102,7 @@ async def _start_obsidian_sync_notification(
operation_id=operation_id,
title=f"Syncing: {connector_name}",
message="Syncing from Obsidian plugin",
search_space_id=connector.search_space_id,
workspace_id=connector.workspace_id,
initial_metadata={
"connector_id": connector.id,
"connector_name": connector_name,
@ -195,7 +195,7 @@ async def _resolve_vault_connector(
connector = (await session.execute(stmt)).scalars().first()
if connector is not None:
await check_search_space_access(session, auth, connector.search_space_id)
await check_workspace_access(session, auth, connector.workspace_id)
return connector
raise HTTPException(
@ -222,17 +222,17 @@ def _queue_obsidian_attachment(
)
async def _ensure_search_space_access(
async def _ensure_workspace_access(
session: AsyncSession,
*,
auth: AuthContext,
search_space_id: int,
) -> SearchSpace:
"""Owner-only access to the search space (shared spaces are a follow-up)."""
workspace_id: int,
) -> Workspace:
"""Owner-only access to the workspace (shared spaces are a follow-up)."""
user = auth.user
result = await session.execute(
select(SearchSpace).where(
and_(SearchSpace.id == search_space_id, SearchSpace.user_id == user.id)
select(Workspace).where(
and_(Workspace.id == workspace_id, Workspace.user_id == user.id)
)
)
space = result.scalars().first()
@ -241,10 +241,10 @@ async def _ensure_search_space_access(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "SEARCH_SPACE_FORBIDDEN",
"message": "You don't own that search space.",
"message": "You don't own that workspace.",
},
)
await check_search_space_access(session, auth, search_space_id)
await check_workspace_access(session, auth, workspace_id)
return space
@ -326,8 +326,8 @@ async def obsidian_connect(
Fingerprint collisions on (1) trigger ``merge_obsidian_connectors`` so
the partial unique index can never produce two live rows for one vault.
"""
await _ensure_search_space_access(
session, auth=auth, search_space_id=payload.search_space_id
await _ensure_workspace_access(
session, auth=auth, workspace_id=payload.workspace_id
)
user = auth.user
@ -354,7 +354,7 @@ async def obsidian_connect(
response = ConnectResponse(
connector_id=collision.id,
vault_id=collision_cfg["vault_id"],
search_space_id=collision.search_space_id,
workspace_id=collision.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
@ -363,12 +363,12 @@ async def obsidian_connect(
existing_by_vid.name = display_name
existing_by_vid.config = cfg
existing_by_vid.search_space_id = payload.search_space_id
existing_by_vid.workspace_id = payload.workspace_id
existing_by_vid.is_indexable = False
response = ConnectResponse(
connector_id=existing_by_vid.id,
vault_id=payload.vault_id,
search_space_id=existing_by_vid.search_space_id,
workspace_id=existing_by_vid.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
@ -387,7 +387,7 @@ async def obsidian_connect(
response = ConnectResponse(
connector_id=existing_by_fp.id,
vault_id=survivor_cfg["vault_id"],
search_space_id=existing_by_fp.search_space_id,
workspace_id=existing_by_fp.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
@ -406,12 +406,12 @@ async def obsidian_connect(
is_indexable=False,
config=cfg,
user_id=user.id,
search_space_id=payload.search_space_id,
workspace_id=payload.workspace_id,
)
.on_conflict_do_nothing()
.returning(
SearchSourceConnector.id,
SearchSourceConnector.search_space_id,
SearchSourceConnector.workspace_id,
)
)
inserted = (await session.execute(insert_stmt)).first()
@ -419,7 +419,7 @@ async def obsidian_connect(
response = ConnectResponse(
connector_id=inserted.id,
vault_id=payload.vault_id,
search_space_id=inserted.search_space_id,
workspace_id=inserted.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
@ -441,7 +441,7 @@ async def obsidian_connect(
response = ConnectResponse(
connector_id=winner.id,
vault_id=(winner.config or {})["vault_id"],
search_space_id=winner.search_space_id,
workspace_id=winner.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)

View file

@ -36,7 +36,7 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
router = APIRouter()
@ -134,7 +134,7 @@ async def reauth_onedrive(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
@ -312,7 +312,7 @@ async def onedrive_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
@ -379,7 +379,7 @@ async def onedrive_callback(
connector_type=SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
@ -438,7 +438,7 @@ async def list_onedrive_folders(
status_code=404, detail="OneDrive connector not found or access denied"
)
await check_search_space_access(session, auth, connector.search_space_id)
await check_workspace_access(session, auth, connector.workspace_id)
onedrive_client = OneDriveClient(session, connector_id)
items, error = await list_folder_contents(onedrive_client, parent_id=parent_id)

View file

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
from app.db import Prompt, SearchSpaceMembership, get_async_session
from app.db import Prompt, WorkspaceMembership, get_async_session
from app.schemas.prompts import (
PromptCreate,
PromptRead,
@ -18,14 +18,14 @@ router = APIRouter(tags=["Prompts"])
@router.get("/prompts", response_model=list[PromptRead])
async def list_prompts(
search_space_id: int | None = None,
workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(require_session_context),
):
user = auth.user
query = select(Prompt).where(Prompt.user_id == user.id)
if search_space_id is not None:
query = query.where(Prompt.search_space_id == search_space_id)
if workspace_id is not None:
query = query.where(Prompt.workspace_id == workspace_id)
query = query.order_by(Prompt.created_at.desc())
result = await session.execute(query)
return result.scalars().all()
@ -38,22 +38,22 @@ async def create_prompt(
auth: AuthContext = Depends(require_session_context),
):
user = auth.user
if body.search_space_id is not None:
if body.workspace_id is not None:
membership = await session.execute(
select(SearchSpaceMembership).where(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == body.search_space_id,
select(WorkspaceMembership).where(
WorkspaceMembership.user_id == user.id,
WorkspaceMembership.workspace_id == body.workspace_id,
)
)
if not membership.scalar_one_or_none():
raise HTTPException(
status_code=403,
detail="You are not a member of this search space",
detail="You are not a member of this workspace",
)
prompt = Prompt(
user_id=user.id,
search_space_id=body.search_space_id,
workspace_id=body.workspace_id,
name=body.name,
prompt=body.prompt,
mode=body.mode,

View file

@ -2,9 +2,9 @@
RBAC (Role-Based Access Control) routes for managing roles, memberships, and invites.
Endpoints:
- /searchspaces/{search_space_id}/roles - CRUD for roles
- /searchspaces/{search_space_id}/members - CRUD for memberships
- /searchspaces/{search_space_id}/invites - CRUD for invites
- /workspaces/{workspace_id}/roles - CRUD for roles
- /workspaces/{workspace_id}/members - CRUD for memberships
- /workspaces/{workspace_id}/invites - CRUD for invites
- /invites/{invite_code}/info - Get invite info (public)
- /invites/accept - Accept an invite
- /permissions - List all available permissions
@ -21,10 +21,10 @@ from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
from app.db import (
Permission,
SearchSpace,
SearchSpaceInvite,
SearchSpaceMembership,
SearchSpaceRole,
Workspace,
WorkspaceInvite,
WorkspaceMembership,
WorkspaceRole,
User,
get_async_session,
)
@ -42,12 +42,12 @@ from app.schemas import (
RoleCreate,
RoleRead,
RoleUpdate,
UserSearchSpaceAccess,
UserWorkspaceAccess,
)
from app.users import get_auth_context
from app.utils.rbac import (
check_permission,
check_search_space_access,
check_workspace_access,
generate_invite_code,
get_default_role,
get_user_permissions,
@ -63,10 +63,10 @@ router = APIRouter()
# Human-readable descriptions for each permission
PERMISSION_DESCRIPTIONS = {
# Documents
"documents:create": "Add new documents, files, and content to the search space",
"documents:read": "View and search documents in the search space",
"documents:create": "Add new documents, files, and content to the workspace",
"documents:read": "View and search documents in the workspace",
"documents:update": "Edit existing documents and their metadata",
"documents:delete": "Remove documents from the search space",
"documents:delete": "Remove documents from the workspace",
# Chats
"chats:create": "Start new AI chat conversations",
"chats:read": "View chat history and conversations",
@ -97,7 +97,7 @@ PERMISSION_DESCRIPTIONS = {
# Members
"members:invite": "Send invitations to new team members",
"members:view": "View the list of team members",
"members:remove": "Remove members from the search space",
"members:remove": "Remove members from the workspace",
"members:manage_roles": "Assign and change member roles",
# Roles
"roles:create": "Create new custom roles",
@ -105,16 +105,16 @@ PERMISSION_DESCRIPTIONS = {
"roles:update": "Modify role permissions",
"roles:delete": "Remove custom roles",
# Settings
"settings:view": "View search space settings",
"settings:update": "Modify search space settings",
"settings:delete": "Delete the entire search space",
"settings:view": "View workspace settings",
"settings:update": "Modify workspace settings",
"settings:delete": "Delete the entire workspace",
# API access
"api_access:manage": "Enable or disable programmatic API access for a search space",
"api_access:manage": "Enable or disable programmatic API access for a workspace",
# Automations
"automations:create": "Create automations from chat or JSON",
"automations:read": "View automations, their triggers, and run history",
"automations:update": "Edit automations and manage their triggers",
"automations:delete": "Remove automations from the search space",
"automations:delete": "Remove automations from the workspace",
"automations:execute": "Manually fire automations",
# Full access
"*": "Full access to all features and settings",
@ -152,39 +152,39 @@ async def list_all_permissions(
@router.post(
"/searchspaces/{search_space_id}/roles",
"/workspaces/{workspace_id}/roles",
response_model=RoleRead,
)
async def create_role(
search_space_id: int,
workspace_id: int,
role_data: RoleCreate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
Create a new custom role in a search space.
Create a new custom role in a workspace.
Requires ROLES_CREATE permission.
"""
try:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.ROLES_CREATE.value,
"You don't have permission to create roles",
)
# Check if role with same name already exists
result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id,
SearchSpaceRole.name == role_data.name,
select(WorkspaceRole).filter(
WorkspaceRole.workspace_id == workspace_id,
WorkspaceRole.name == role_data.name,
)
)
if result.scalars().first():
raise HTTPException(
status_code=409,
detail=f"A role with name '{role_data.name}' already exists in this search space",
detail=f"A role with name '{role_data.name}' already exists in this workspace",
)
# Validate permissions
@ -199,23 +199,23 @@ async def create_role(
# If setting is_default to True, unset any existing default
if role_data.is_default:
await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id,
SearchSpaceRole.is_default == True, # noqa: E712
select(WorkspaceRole).filter(
WorkspaceRole.workspace_id == workspace_id,
WorkspaceRole.is_default == True, # noqa: E712
)
)
existing_defaults = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id,
SearchSpaceRole.is_default == True, # noqa: E712
select(WorkspaceRole).filter(
WorkspaceRole.workspace_id == workspace_id,
WorkspaceRole.is_default == True, # noqa: E712
)
)
for existing in existing_defaults.scalars().all():
existing.is_default = False
db_role = SearchSpaceRole(
db_role = WorkspaceRole(
**role_data.model_dump(),
search_space_id=search_space_id,
workspace_id=workspace_id,
is_system_role=False,
)
session.add(db_role)
@ -234,30 +234,30 @@ async def create_role(
@router.get(
"/searchspaces/{search_space_id}/roles",
"/workspaces/{workspace_id}/roles",
response_model=list[RoleRead],
)
async def list_roles(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
List all roles in a search space.
List all roles in a workspace.
Requires ROLES_READ permission.
"""
try:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.ROLES_READ.value,
"You don't have permission to view roles",
)
result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id
select(WorkspaceRole).filter(
WorkspaceRole.workspace_id == workspace_id
)
)
return result.scalars().all()
@ -271,11 +271,11 @@ async def list_roles(
@router.get(
"/searchspaces/{search_space_id}/roles/{role_id}",
"/workspaces/{workspace_id}/roles/{role_id}",
response_model=RoleRead,
)
async def get_role(
search_space_id: int,
workspace_id: int,
role_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -288,15 +288,15 @@ async def get_role(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.ROLES_READ.value,
"You don't have permission to view roles",
)
result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.id == role_id,
SearchSpaceRole.search_space_id == search_space_id,
select(WorkspaceRole).filter(
WorkspaceRole.id == role_id,
WorkspaceRole.workspace_id == workspace_id,
)
)
role = result.scalars().first()
@ -315,11 +315,11 @@ async def get_role(
@router.put(
"/searchspaces/{search_space_id}/roles/{role_id}",
"/workspaces/{workspace_id}/roles/{role_id}",
response_model=RoleRead,
)
async def update_role(
search_space_id: int,
workspace_id: int,
role_id: int,
role_update: RoleUpdate,
session: AsyncSession = Depends(get_async_session),
@ -334,15 +334,15 @@ async def update_role(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.ROLES_UPDATE.value,
"You don't have permission to update roles",
)
result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.id == role_id,
SearchSpaceRole.search_space_id == search_space_id,
select(WorkspaceRole).filter(
WorkspaceRole.id == role_id,
WorkspaceRole.workspace_id == workspace_id,
)
)
db_role = result.scalars().first()
@ -365,9 +365,9 @@ async def update_role(
# Check for name conflict if updating name
if "name" in update_data and update_data["name"] != db_role.name:
existing = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id,
SearchSpaceRole.name == update_data["name"],
select(WorkspaceRole).filter(
WorkspaceRole.workspace_id == workspace_id,
WorkspaceRole.name == update_data["name"],
)
)
if existing.scalars().first():
@ -390,9 +390,9 @@ async def update_role(
if update_data.get("is_default") and not db_role.is_default:
# Unset existing default
existing_defaults = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id,
SearchSpaceRole.is_default == True, # noqa: E712
select(WorkspaceRole).filter(
WorkspaceRole.workspace_id == workspace_id,
WorkspaceRole.is_default == True, # noqa: E712
)
)
for existing in existing_defaults.scalars().all():
@ -415,9 +415,9 @@ async def update_role(
) from e
@router.delete("/searchspaces/{search_space_id}/roles/{role_id}")
@router.delete("/workspaces/{workspace_id}/roles/{role_id}")
async def delete_role(
search_space_id: int,
workspace_id: int,
role_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -431,15 +431,15 @@ async def delete_role(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.ROLES_DELETE.value,
"You don't have permission to delete roles",
)
result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.id == role_id,
SearchSpaceRole.search_space_id == search_space_id,
select(WorkspaceRole).filter(
WorkspaceRole.id == role_id,
WorkspaceRole.workspace_id == workspace_id,
)
)
db_role = result.scalars().first()
@ -471,31 +471,31 @@ async def delete_role(
@router.get(
"/searchspaces/{search_space_id}/members",
"/workspaces/{workspace_id}/members",
response_model=list[MembershipRead],
)
async def list_members(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
List all members of a search space.
List all members of a workspace.
Requires MEMBERS_VIEW permission.
"""
try:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.MEMBERS_VIEW.value,
"You don't have permission to view members",
)
result = await session.execute(
select(SearchSpaceMembership)
.options(selectinload(SearchSpaceMembership.role))
.filter(SearchSpaceMembership.search_space_id == search_space_id)
select(WorkspaceMembership)
.options(selectinload(WorkspaceMembership.role))
.filter(WorkspaceMembership.workspace_id == workspace_id)
)
memberships = result.scalars().all()
@ -510,7 +510,7 @@ async def list_members(
membership_dict = {
"id": membership.id,
"user_id": membership.user_id,
"search_space_id": membership.search_space_id,
"workspace_id": membership.workspace_id,
"role_id": membership.role_id,
"is_owner": membership.is_owner,
"joined_at": membership.joined_at,
@ -534,11 +534,11 @@ async def list_members(
@router.put(
"/searchspaces/{search_space_id}/members/{membership_id}",
"/workspaces/{workspace_id}/members/{membership_id}",
response_model=MembershipRead,
)
async def update_member_role(
search_space_id: int,
workspace_id: int,
membership_id: int,
membership_update: MembershipUpdate,
session: AsyncSession = Depends(get_async_session),
@ -553,17 +553,17 @@ async def update_member_role(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.MEMBERS_MANAGE_ROLES.value,
"You don't have permission to manage member roles",
)
result = await session.execute(
select(SearchSpaceMembership)
.options(selectinload(SearchSpaceMembership.role))
select(WorkspaceMembership)
.options(selectinload(WorkspaceMembership.role))
.filter(
SearchSpaceMembership.id == membership_id,
SearchSpaceMembership.search_space_id == search_space_id,
WorkspaceMembership.id == membership_id,
WorkspaceMembership.workspace_id == workspace_id,
)
)
db_membership = result.scalars().first()
@ -578,18 +578,18 @@ async def update_member_role(
detail="Cannot change the owner's role",
)
# Verify the new role exists in this search space
# Verify the new role exists in this workspace
if membership_update.role_id:
role_result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.id == membership_update.role_id,
SearchSpaceRole.search_space_id == search_space_id,
select(WorkspaceRole).filter(
WorkspaceRole.id == membership_update.role_id,
WorkspaceRole.workspace_id == workspace_id,
)
)
if not role_result.scalars().first():
raise HTTPException(
status_code=404,
detail="Role not found in this search space",
detail="Role not found in this workspace",
)
db_membership.role_id = membership_update.role_id
@ -605,7 +605,7 @@ async def update_member_role(
return {
"id": db_membership.id,
"user_id": db_membership.user_id,
"search_space_id": db_membership.search_space_id,
"workspace_id": db_membership.workspace_id,
"role_id": db_membership.role_id,
"is_owner": db_membership.is_owner,
"joined_at": db_membership.joined_at,
@ -628,22 +628,22 @@ async def update_member_role(
# NOTE: /members/me must be defined BEFORE /members/{membership_id}
# because FastAPI matches routes in order, and "me" would otherwise
# be interpreted as a membership_id (causing a 422 validation error)
@router.delete("/searchspaces/{search_space_id}/members/me")
async def leave_search_space(
search_space_id: int,
@router.delete("/workspaces/{workspace_id}/members/me")
async def leave_workspace(
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Leave a search space (remove own membership).
Owners cannot leave their search space.
Leave a workspace (remove own membership).
Owners cannot leave their workspace.
"""
try:
result = await session.execute(
select(SearchSpaceMembership).filter(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == search_space_id,
select(WorkspaceMembership).filter(
WorkspaceMembership.user_id == user.id,
WorkspaceMembership.workspace_id == workspace_id,
)
)
db_membership = result.scalars().first()
@ -651,38 +651,38 @@ async def leave_search_space(
if not db_membership:
raise HTTPException(
status_code=404,
detail="You are not a member of this search space",
detail="You are not a member of this workspace",
)
if db_membership.is_owner:
raise HTTPException(
status_code=400,
detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.",
detail="Owners cannot leave their workspace. Transfer ownership first or delete the workspace.",
)
await session.delete(db_membership)
await session.commit()
return {"message": "Successfully left the search space"}
return {"message": "Successfully left the workspace"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(f"Failed to leave search space: {e!s}", exc_info=True)
logger.error(f"Failed to leave workspace: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to leave search space: {e!s}"
status_code=500, detail=f"Failed to leave workspace: {e!s}"
) from e
@router.delete("/searchspaces/{search_space_id}/members/{membership_id}")
@router.delete("/workspaces/{workspace_id}/members/{membership_id}")
async def remove_member(
search_space_id: int,
workspace_id: int,
membership_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
Remove a member from a search space.
Remove a member from a workspace.
Requires MEMBERS_REMOVE permission.
Cannot remove the owner.
"""
@ -690,15 +690,15 @@ async def remove_member(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.MEMBERS_REMOVE.value,
"You don't have permission to remove members",
)
result = await session.execute(
select(SearchSpaceMembership).filter(
SearchSpaceMembership.id == membership_id,
SearchSpaceMembership.search_space_id == search_space_id,
select(WorkspaceMembership).filter(
WorkspaceMembership.id == membership_id,
WorkspaceMembership.workspace_id == workspace_id,
)
)
db_membership = result.scalars().first()
@ -709,7 +709,7 @@ async def remove_member(
if db_membership.is_owner:
raise HTTPException(
status_code=400,
detail="Cannot remove the owner from the search space",
detail="Cannot remove the owner from the workspace",
)
await session.delete(db_membership)
@ -730,25 +730,25 @@ async def remove_member(
@router.post(
"/searchspaces/{search_space_id}/invites",
"/workspaces/{workspace_id}/invites",
response_model=InviteRead,
)
async def create_invite(
search_space_id: int,
workspace_id: int,
invite_data: InviteCreate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Create a new invite link for a search space.
Create a new invite link for a workspace.
Requires MEMBERS_INVITE permission.
"""
try:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to create invites",
)
@ -756,21 +756,21 @@ async def create_invite(
# Verify role exists if specified
if invite_data.role_id:
role_result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.id == invite_data.role_id,
SearchSpaceRole.search_space_id == search_space_id,
select(WorkspaceRole).filter(
WorkspaceRole.id == invite_data.role_id,
WorkspaceRole.workspace_id == workspace_id,
)
)
if not role_result.scalars().first():
raise HTTPException(
status_code=404,
detail="Role not found in this search space",
detail="Role not found in this workspace",
)
db_invite = SearchSpaceInvite(
db_invite = WorkspaceInvite(
**invite_data.model_dump(),
invite_code=generate_invite_code(),
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=user.id,
)
session.add(db_invite)
@ -778,9 +778,9 @@ async def create_invite(
# Reload with role
result = await session.execute(
select(SearchSpaceInvite)
.options(selectinload(SearchSpaceInvite.role))
.filter(SearchSpaceInvite.id == db_invite.id)
select(WorkspaceInvite)
.options(selectinload(WorkspaceInvite.role))
.filter(WorkspaceInvite.id == db_invite.id)
)
db_invite = result.scalars().first()
@ -797,31 +797,31 @@ async def create_invite(
@router.get(
"/searchspaces/{search_space_id}/invites",
"/workspaces/{workspace_id}/invites",
response_model=list[InviteRead],
)
async def list_invites(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
List all invites for a search space.
List all invites for a workspace.
Requires MEMBERS_INVITE permission.
"""
try:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to view invites",
)
result = await session.execute(
select(SearchSpaceInvite)
.options(selectinload(SearchSpaceInvite.role))
.filter(SearchSpaceInvite.search_space_id == search_space_id)
select(WorkspaceInvite)
.options(selectinload(WorkspaceInvite.role))
.filter(WorkspaceInvite.workspace_id == workspace_id)
)
return result.scalars().all()
@ -834,11 +834,11 @@ async def list_invites(
@router.put(
"/searchspaces/{search_space_id}/invites/{invite_id}",
"/workspaces/{workspace_id}/invites/{invite_id}",
response_model=InviteRead,
)
async def update_invite(
search_space_id: int,
workspace_id: int,
invite_id: int,
invite_update: InviteUpdate,
session: AsyncSession = Depends(get_async_session),
@ -852,17 +852,17 @@ async def update_invite(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to update invites",
)
result = await session.execute(
select(SearchSpaceInvite)
.options(selectinload(SearchSpaceInvite.role))
select(WorkspaceInvite)
.options(selectinload(WorkspaceInvite.role))
.filter(
SearchSpaceInvite.id == invite_id,
SearchSpaceInvite.search_space_id == search_space_id,
WorkspaceInvite.id == invite_id,
WorkspaceInvite.workspace_id == workspace_id,
)
)
db_invite = result.scalars().first()
@ -875,15 +875,15 @@ async def update_invite(
# Verify role exists if updating role_id
if update_data.get("role_id"):
role_result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.id == update_data["role_id"],
SearchSpaceRole.search_space_id == search_space_id,
select(WorkspaceRole).filter(
WorkspaceRole.id == update_data["role_id"],
WorkspaceRole.workspace_id == workspace_id,
)
)
if not role_result.scalars().first():
raise HTTPException(
status_code=404,
detail="Role not found in this search space",
detail="Role not found in this workspace",
)
for key, value in update_data.items():
@ -903,9 +903,9 @@ async def update_invite(
) from e
@router.delete("/searchspaces/{search_space_id}/invites/{invite_id}")
@router.delete("/workspaces/{workspace_id}/invites/{invite_id}")
async def revoke_invite(
search_space_id: int,
workspace_id: int,
invite_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@ -918,15 +918,15 @@ async def revoke_invite(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to revoke invites",
)
result = await session.execute(
select(SearchSpaceInvite).filter(
SearchSpaceInvite.id == invite_id,
SearchSpaceInvite.search_space_id == search_space_id,
select(WorkspaceInvite).filter(
WorkspaceInvite.id == invite_id,
WorkspaceInvite.workspace_id == workspace_id,
)
)
db_invite = result.scalars().first()
@ -962,18 +962,18 @@ async def get_invite_info(
"""
try:
result = await session.execute(
select(SearchSpaceInvite)
select(WorkspaceInvite)
.options(
selectinload(SearchSpaceInvite.role),
selectinload(SearchSpaceInvite.search_space),
selectinload(WorkspaceInvite.role),
selectinload(WorkspaceInvite.workspace),
)
.filter(SearchSpaceInvite.invite_code == invite_code)
.filter(WorkspaceInvite.invite_code == invite_code)
)
invite = result.scalars().first()
if not invite:
return InviteInfoResponse(
search_space_name="",
workspace_name="",
role_name=None,
is_valid=False,
message="Invite not found",
@ -982,8 +982,8 @@ async def get_invite_info(
# Check if invite is still valid
if not invite.is_active:
return InviteInfoResponse(
search_space_name=invite.search_space.name
if invite.search_space
workspace_name=invite.workspace.name
if invite.workspace
else "",
role_name=invite.role.name if invite.role else None,
is_valid=False,
@ -992,8 +992,8 @@ async def get_invite_info(
if invite.expires_at and invite.expires_at < datetime.now(UTC):
return InviteInfoResponse(
search_space_name=invite.search_space.name
if invite.search_space
workspace_name=invite.workspace.name
if invite.workspace
else "",
role_name=invite.role.name if invite.role else None,
is_valid=False,
@ -1002,8 +1002,8 @@ async def get_invite_info(
if invite.max_uses and invite.uses_count >= invite.max_uses:
return InviteInfoResponse(
search_space_name=invite.search_space.name
if invite.search_space
workspace_name=invite.workspace.name
if invite.workspace
else "",
role_name=invite.role.name if invite.role else None,
is_valid=False,
@ -1011,7 +1011,7 @@ async def get_invite_info(
)
return InviteInfoResponse(
search_space_name=invite.search_space.name if invite.search_space else "",
workspace_name=invite.workspace.name if invite.workspace else "",
role_name=invite.role.name if invite.role else "Default",
is_valid=True,
)
@ -1031,16 +1031,16 @@ async def accept_invite(
):
user = auth.user
"""
Accept an invite and join a search space.
Accept an invite and join a workspace.
"""
try:
result = await session.execute(
select(SearchSpaceInvite)
select(WorkspaceInvite)
.options(
selectinload(SearchSpaceInvite.role),
selectinload(SearchSpaceInvite.search_space),
selectinload(WorkspaceInvite.role),
selectinload(WorkspaceInvite.workspace),
)
.filter(SearchSpaceInvite.invite_code == request.invite_code)
.filter(WorkspaceInvite.invite_code == request.invite_code)
)
invite = result.scalars().first()
@ -1063,28 +1063,28 @@ async def accept_invite(
# Check if user is already a member
existing_membership = await session.execute(
select(SearchSpaceMembership).filter(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == invite.search_space_id,
select(WorkspaceMembership).filter(
WorkspaceMembership.user_id == user.id,
WorkspaceMembership.workspace_id == invite.workspace_id,
)
)
if existing_membership.scalars().first():
raise HTTPException(
status_code=400,
detail="You are already a member of this search space",
detail="You are already a member of this workspace",
)
# Determine role to assign
role_id = invite.role_id
if not role_id:
# Use default role
default_role = await get_default_role(session, invite.search_space_id)
default_role = await get_default_role(session, invite.workspace_id)
role_id = default_role.id if default_role else None
# Create membership
membership = SearchSpaceMembership(
membership = WorkspaceMembership(
user_id=user.id,
search_space_id=invite.search_space_id,
workspace_id=invite.workspace_id,
role_id=role_id,
is_owner=False,
invited_by_invite_id=invite.id,
@ -1097,12 +1097,12 @@ async def accept_invite(
await session.commit()
role_name = invite.role.name if invite.role else "Default"
search_space_name = invite.search_space.name if invite.search_space else ""
workspace_name = invite.workspace.name if invite.workspace else ""
return InviteAcceptResponse(
message="Successfully joined the search space",
search_space_id=invite.search_space_id,
search_space_name=search_space_name,
message="Successfully joined the workspace",
workspace_id=invite.workspace_id,
workspace_name=workspace_name,
role_name=role_name,
)
@ -1120,33 +1120,33 @@ async def accept_invite(
@router.get(
"/searchspaces/{search_space_id}/my-access",
response_model=UserSearchSpaceAccess,
"/workspaces/{workspace_id}/my-access",
response_model=UserWorkspaceAccess,
)
async def get_my_access(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Get the current user's access info for a search space.
Get the current user's access info for a workspace.
"""
try:
membership = await check_search_space_access(session, auth, search_space_id)
membership = await check_workspace_access(session, auth, workspace_id)
# Get search space name
# Get workspace name
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
select(Workspace).filter(Workspace.id == workspace_id)
)
search_space = result.scalars().first()
workspace = result.scalars().first()
# Get permissions
permissions = await get_user_permissions(session, user.id, search_space_id)
permissions = await get_user_permissions(session, user.id, workspace_id)
return UserSearchSpaceAccess(
search_space_id=search_space_id,
search_space_name=search_space.name if search_space else "",
return UserWorkspaceAccess(
workspace_id=workspace_id,
workspace_name=workspace.name if workspace else "",
is_owner=membership.is_owner,
role_name=membership.role.name if membership.role else None,
permissions=permissions,

View file

@ -8,7 +8,7 @@ Export is on-demand in multiple formats (PDF, DOCX, HTML, LaTeX, EPUB, ODT,
plain text). PDF uses pypandoc (Markdown->Typst) + typst-py; the rest use
pypandoc directly with format-specific templates and options.
Authorization: lightweight search-space membership checks (no granular RBAC)
Authorization: lightweight workspace membership checks (no granular RBAC)
since reports are chat-generated artifacts, not standalone managed resources.
"""
@ -31,8 +31,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import (
Report,
SearchSpace,
SearchSpaceMembership,
Workspace,
WorkspaceMembership,
get_async_session,
)
from app.schemas import ReportContentRead, ReportContentUpdate, ReportRead
@ -43,7 +43,7 @@ from app.templates.export_helpers import (
get_typst_template_path,
)
from app.users import get_auth_context
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@ -160,7 +160,7 @@ async def _get_report_with_access(
session: AsyncSession,
auth: AuthContext,
) -> Report:
"""Fetch a report and verify the user belongs to its search space.
"""Fetch a report and verify the user belongs to its workspace.
Raises HTTPException(404) if not found, HTTPException(403) if no access.
"""
@ -171,8 +171,8 @@ async def _get_report_with_access(
raise HTTPException(status_code=404, detail="Report not found")
# Lightweight membership check - no granular RBAC, just "is the user a
# member of the search space this report belongs to?"
await check_search_space_access(session, auth, report.search_space_id)
# member of the workspace this report belongs to?"
await check_workspace_access(session, auth, report.workspace_id)
return report
@ -204,23 +204,23 @@ async def _get_version_siblings(
async def read_reports(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=MAX_REPORT_LIST_LIMIT),
search_space_id: int | None = None,
workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
List reports the user has access to.
Filters by search space membership.
Filters by workspace membership.
"""
try:
if search_space_id is not None:
# Verify the caller is a member of the requested search space
await check_search_space_access(session, auth, search_space_id)
if workspace_id is not None:
# Verify the caller is a member of the requested workspace
await check_workspace_access(session, auth, workspace_id)
result = await session.execute(
select(Report)
.filter(Report.search_space_id == search_space_id)
.filter(Report.workspace_id == workspace_id)
.order_by(Report.id.desc())
.offset(skip)
.limit(limit)
@ -228,9 +228,9 @@ async def read_reports(
else:
result = await session.execute(
select(Report)
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
.order_by(Report.id.desc())
.offset(skip)
.limit(limit)
@ -307,7 +307,7 @@ async def update_report_content(
"""
Update the Markdown content of a report.
The caller must be a member of the search space the report belongs to.
The caller must be a member of the workspace the report belongs to.
Returns the updated report content including version siblings.
"""
try:

View file

@ -70,7 +70,7 @@ async def download_sandbox_file(
await check_permission(
session,
auth,
thread.search_space_id,
thread.workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to access files in this thread",
)

View file

@ -32,7 +32,7 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@ -87,7 +87,7 @@ async def connect_slack(
Initiate Slack OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -319,7 +319,7 @@ async def slack_callback(
connector_type=SearchSourceConnectorType.SLACK_CONNECTOR,
is_indexable=False,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@ -565,7 +565,7 @@ async def get_slack_channels(
detail="Slack connector not found or access denied",
)
await check_search_space_access(session, auth, connector.search_space_id)
await check_workspace_access(session, auth, connector.workspace_id)
# Get credentials and decrypt bot token
credentials = SlackAuthCredentialsBase.from_dict(connector.config)

View file

@ -65,7 +65,7 @@ def _ensure_credit_buying_enabled() -> None:
)
def _get_checkout_urls(search_space_id: int) -> tuple[str, str]:
def _get_checkout_urls(workspace_id: int) -> tuple[str, str]:
if not config.NEXT_FRONTEND_URL:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@ -79,10 +79,10 @@ def _get_checkout_urls(search_space_id: int) -> tuple[str, str]:
# webhook-vs-redirect race where users land on /purchase-success before
# checkout.session.completed has been delivered.
success_url = (
f"{base_url}/dashboard/{search_space_id}/purchase-success"
f"{base_url}/dashboard/{workspace_id}/purchase-success"
f"?session_id={{CHECKOUT_SESSION_ID}}"
)
cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
cancel_url = f"{base_url}/dashboard/{workspace_id}/purchase-cancel"
return success_url, cancel_url
@ -471,7 +471,7 @@ async def create_credit_checkout_session(
_ensure_credit_buying_enabled()
stripe_client = get_stripe_client()
price_id = _get_required_credit_price_id()
success_url, cancel_url = _get_checkout_urls(body.search_space_id)
success_url, cancel_url = _get_checkout_urls(body.workspace_id)
credit_micros_granted = body.quantity * config.STRIPE_CREDIT_MICROS_PER_UNIT
try:
@ -832,11 +832,11 @@ async def create_auto_reload_setup_session(
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
success_url = (
f"{base_url}/dashboard/{body.search_space_id}/user-settings/purchases"
f"{base_url}/dashboard/{body.workspace_id}/user-settings/purchases"
f"?auto_reload_setup=success"
)
cancel_url = (
f"{base_url}/dashboard/{body.search_space_id}/user-settings/purchases"
f"{base_url}/dashboard/{body.workspace_id}/user-settings/purchases"
f"?auto_reload_setup=cancel"
)

View file

@ -1,4 +1,4 @@
"""Routes for search-space team memory."""
"""Routes for workspace team memory."""
from __future__ import annotations
@ -17,7 +17,7 @@ from app.services.memory import (
save_memory,
)
from app.users import get_auth_context
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
router = APIRouter()
@ -26,32 +26,32 @@ class TeamMemoryUpdate(BaseModel):
memory_md: str
@router.get("/searchspaces/{search_space_id}/memory", response_model=MemoryRead)
@router.get("/workspaces/{workspace_id}/memory", response_model=MemoryRead)
async def get_team_memory(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
await check_search_space_access(session, auth, search_space_id)
await check_workspace_access(session, auth, workspace_id)
memory_md = await read_memory(
scope=MemoryScope.TEAM,
target_id=search_space_id,
target_id=workspace_id,
session=session,
)
return MemoryRead(memory_md=memory_md, limits=memory_limits())
@router.put("/searchspaces/{search_space_id}/memory", response_model=MemoryRead)
@router.put("/workspaces/{workspace_id}/memory", response_model=MemoryRead)
async def update_team_memory(
search_space_id: int,
workspace_id: int,
body: TeamMemoryUpdate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
await check_search_space_access(session, auth, search_space_id)
await check_workspace_access(session, auth, workspace_id)
result = await save_memory(
scope=MemoryScope.TEAM,
target_id=search_space_id,
target_id=workspace_id,
content=body.memory_md,
session=session,
)
@ -60,16 +60,16 @@ async def update_team_memory(
return MemoryRead(memory_md=result.memory_md, limits=memory_limits())
@router.post("/searchspaces/{search_space_id}/memory/reset", response_model=MemoryRead)
@router.post("/workspaces/{workspace_id}/memory/reset", response_model=MemoryRead)
async def reset_team_memory(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
await check_search_space_access(session, auth, search_space_id)
await check_workspace_access(session, auth, workspace_id)
result = await reset_memory(
scope=MemoryScope.TEAM,
target_id=search_space_id,
target_id=workspace_id,
session=session,
)
if result.status == "error":

View file

@ -82,7 +82,7 @@ async def connect_teams(
Initiate Microsoft Teams OAuth flow.
Args:
space_id: The search space ID
space_id: The workspace ID
user: Current authenticated user
Returns:
@ -327,7 +327,7 @@ async def teams_callback(
connector_type=SearchSourceConnectorType.TEAMS_CONNECTOR,
is_indexable=False,
config=connector_config,
search_space_id=space_id,
workspace_id=space_id,
user_id=user_id,
)

View file

@ -19,8 +19,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import (
Permission,
SearchSpace,
SearchSpaceMembership,
Workspace,
WorkspaceMembership,
VideoPresentation,
get_async_session,
)
@ -35,38 +35,38 @@ router = APIRouter()
async def read_video_presentations(
skip: int = 0,
limit: int = 100,
search_space_id: int | None = None,
workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
List video presentations the user has access to.
Requires VIDEO_PRESENTATIONS_READ permission for the search space(s).
Requires VIDEO_PRESENTATIONS_READ permission for the workspace(s).
"""
if skip < 0 or limit < 1:
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
try:
if search_space_id is not None:
if workspace_id is not None:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
"You don't have permission to read video presentations in this search space",
"You don't have permission to read video presentations in this workspace",
)
result = await session.execute(
select(VideoPresentation)
.filter(VideoPresentation.search_space_id == search_space_id)
.filter(VideoPresentation.workspace_id == workspace_id)
.offset(skip)
.limit(limit)
)
else:
result = await session.execute(
select(VideoPresentation)
.join(SearchSpace)
.join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id)
.join(Workspace)
.join(WorkspaceMembership)
.filter(WorkspaceMembership.user_id == user.id)
.offset(skip)
.limit(limit)
)
@ -114,9 +114,9 @@ async def read_video_presentation(
await check_permission(
session,
auth,
video_pres.search_space_id,
video_pres.workspace_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
"You don't have permission to read video presentations in this search space",
"You don't have permission to read video presentations in this workspace",
)
return VideoPresentationRead.from_orm_with_slides(video_pres)
@ -137,7 +137,7 @@ async def delete_video_presentation(
):
"""
Delete a video presentation.
Requires VIDEO_PRESENTATIONS_DELETE permission for the search space.
Requires VIDEO_PRESENTATIONS_DELETE permission for the workspace.
"""
try:
result = await session.execute(
@ -153,9 +153,9 @@ async def delete_video_presentation(
await check_permission(
session,
auth,
db_video_pres.search_space_id,
db_video_pres.workspace_id,
Permission.VIDEO_PRESENTATIONS_DELETE.value,
"You don't have permission to delete video presentations in this search space",
"You don't have permission to delete video presentations in this workspace",
)
await session.delete(db_video_pres)
@ -196,9 +196,9 @@ async def stream_slide_audio(
await check_permission(
session,
auth,
video_pres.search_space_id,
video_pres.workspace_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
"You don't have permission to access video presentations in this search space",
"You don't have permission to access video presentations in this workspace",
)
slides = video_pres.slides or []

View file

@ -8,21 +8,21 @@ from sqlalchemy.future import select
from app.auth.context import AuthContext
from app.db import (
Permission,
SearchSpace,
SearchSpaceMembership,
SearchSpaceRole,
Workspace,
WorkspaceMembership,
WorkspaceRole,
get_async_session,
get_default_roles_config,
)
from app.schemas import (
SearchSpaceApiAccessUpdate,
SearchSpaceCreate,
SearchSpaceRead,
SearchSpaceUpdate,
SearchSpaceWithStats,
WorkspaceApiAccessUpdate,
WorkspaceCreate,
WorkspaceRead,
WorkspaceUpdate,
WorkspaceWithStats,
)
from app.users import allow_any_principal, get_auth_context, require_session_context
from app.utils.rbac import check_permission, check_search_space_access
from app.utils.rbac import check_permission, check_workspace_access
logger = logging.getLogger(__name__)
@ -31,29 +31,29 @@ router = APIRouter()
async def create_default_roles_and_membership(
session: AsyncSession,
search_space_id: int,
workspace_id: int,
owner_user_id,
) -> None:
"""
Create default system roles for a search space and add the owner as a member.
Create default system roles for a workspace 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
workspace_id: The ID of the newly created workspace
owner_user_id: The UUID of the user who created the workspace
"""
# Create default roles
default_roles = get_default_roles_config()
owner_role_id = None
for role_config in default_roles:
db_role = SearchSpaceRole(
db_role = WorkspaceRole(
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,
workspace_id=workspace_id,
)
session.add(db_role)
await session.flush() # Get the ID
@ -62,50 +62,50 @@ async def create_default_roles_and_membership(
owner_role_id = db_role.id
# Create owner membership
owner_membership = SearchSpaceMembership(
owner_membership = WorkspaceMembership(
user_id=owner_user_id,
search_space_id=search_space_id,
workspace_id=workspace_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,
@router.post("/workspaces", response_model=WorkspaceRead)
async def create_workspace(
workspace: WorkspaceCreate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(require_session_context),
):
user = auth.user
try:
search_space_data = search_space.model_dump()
workspace_data = workspace.model_dump()
# citations_enabled defaults to True (handled by Pydantic schema)
# qna_custom_instructions defaults to None/empty (handled by DB)
db_search_space = SearchSpace(**search_space_data, user_id=user.id)
session.add(db_search_space)
await session.flush() # Get the search space ID
db_workspace = Workspace(**workspace_data, user_id=user.id)
session.add(db_workspace)
await session.flush() # Get the workspace ID
# Create default roles and owner membership
await create_default_roles_and_membership(session, db_search_space.id, user.id)
await create_default_roles_and_membership(session, db_workspace.id, user.id)
await session.commit()
await session.refresh(db_search_space)
return db_search_space
await session.refresh(db_workspace)
return db_workspace
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(f"Failed to create search space: {e!s}", exc_info=True)
logger.error(f"Failed to create workspace: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to create search space: {e!s}"
status_code=500, detail=f"Failed to create workspace: {e!s}"
) from e
@router.get("/searchspaces", response_model=list[SearchSpaceWithStats])
async def read_search_spaces(
@router.get("/workspaces", response_model=list[WorkspaceWithStats])
async def read_workspaces(
skip: int = 0,
limit: int = 200,
owned_only: bool = False,
@ -114,73 +114,73 @@ async def read_search_spaces(
):
user = auth.user
"""
Get all search spaces the user has access to, with member count and ownership info.
Get all workspaces 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.
owned_only: If True, only return workspaces owned by the user.
If False (default), return all workspaces the user has access to.
"""
try:
# Exclude spaces that are pending background deletion
not_deleting = ~SearchSpace.name.startswith("[DELETING] ")
not_deleting = ~Workspace.name.startswith("[DELETING] ")
api_access_filter = (
SearchSpace.api_access_enabled == True # noqa: E712
Workspace.api_access_enabled == True # noqa: E712
if auth.is_gated
else True
)
if owned_only:
# Return only search spaces where user is the original creator (user_id)
# Return only workspaces where user is the original creator (user_id)
result = await session.execute(
select(SearchSpace)
.filter(SearchSpace.user_id == user.id, not_deleting, api_access_filter)
.order_by(SearchSpace.id.asc())
select(Workspace)
.filter(Workspace.user_id == user.id, not_deleting, api_access_filter)
.order_by(Workspace.id.asc())
.offset(skip)
.limit(limit)
)
else:
# Return all search spaces the user has membership in
# Return all workspaces the user has membership in
result = await session.execute(
select(SearchSpace)
.join(SearchSpaceMembership)
select(Workspace)
.join(WorkspaceMembership)
.filter(
SearchSpaceMembership.user_id == user.id,
WorkspaceMembership.user_id == user.id,
not_deleting,
api_access_filter,
)
.order_by(SearchSpace.id.asc())
.order_by(Workspace.id.asc())
.offset(skip)
.limit(limit)
)
search_spaces = result.scalars().all()
workspaces = result.scalars().all()
# Get member counts and ownership info for each search space
search_spaces_with_stats = []
for space in search_spaces:
# Get member counts and ownership info for each workspace
workspaces_with_stats = []
for space in workspaces:
# Get member count
count_result = await session.execute(
select(func.count(SearchSpaceMembership.id)).filter(
SearchSpaceMembership.search_space_id == space.id
select(func.count(WorkspaceMembership.id)).filter(
WorkspaceMembership.workspace_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
select(WorkspaceMembership).filter(
WorkspaceMembership.workspace_id == space.id,
WorkspaceMembership.user_id == user.id,
WorkspaceMembership.is_owner == True, # noqa: E712
)
)
is_owner = ownership_result.scalars().first() is not None
search_spaces_with_stats.append(
SearchSpaceWithStats(
workspaces_with_stats.append(
WorkspaceWithStats(
id=space.id,
name=space.name,
description=space.description,
@ -195,54 +195,54 @@ async def read_search_spaces(
)
)
return search_spaces_with_stats
return workspaces_with_stats
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to fetch search spaces: {e!s}"
status_code=500, detail=f"Failed to fetch workspaces: {e!s}"
) from e
@router.get("/searchspaces/{search_space_id}", response_model=SearchSpaceRead)
async def read_search_space(
search_space_id: int,
@router.get("/workspaces/{workspace_id}", response_model=WorkspaceRead)
async def read_workspace(
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
Get a specific search space by ID.
Get a specific workspace by ID.
Requires SETTINGS_VIEW permission or membership.
"""
try:
# Check if user has access (is a member)
await check_search_space_access(session, auth, search_space_id)
await check_workspace_access(session, auth, workspace_id)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
select(Workspace).filter(Workspace.id == workspace_id)
)
search_space = result.scalars().first()
workspace = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return search_space
return workspace
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to fetch search space: {e!s}"
status_code=500, detail=f"Failed to fetch workspace: {e!s}"
) from e
@router.put("/searchspaces/{search_space_id}", response_model=SearchSpaceRead)
async def update_search_space(
search_space_id: int,
search_space_update: SearchSpaceUpdate,
@router.put("/workspaces/{workspace_id}", response_model=WorkspaceRead)
async def update_workspace(
workspace_id: int,
workspace_update: WorkspaceUpdate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
Update a search space.
Update a workspace.
Requires SETTINGS_UPDATE permission.
"""
try:
@ -250,46 +250,46 @@ async def update_search_space(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.SETTINGS_UPDATE.value,
"You don't have permission to update this search space",
"You don't have permission to update this workspace",
)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
select(Workspace).filter(Workspace.id == workspace_id)
)
db_search_space = result.scalars().first()
db_workspace = result.scalars().first()
if not db_search_space:
raise HTTPException(status_code=404, detail="Search space not found")
if not db_workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
update_data = search_space_update.model_dump(exclude_unset=True)
update_data = workspace_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_search_space, key, value)
setattr(db_workspace, key, value)
await session.commit()
await session.refresh(db_search_space)
return db_search_space
await session.refresh(db_workspace)
return db_workspace
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to update search space: {e!s}"
status_code=500, detail=f"Failed to update workspace: {e!s}"
) from e
@router.put(
"/searchspaces/{search_space_id}/api-access", response_model=SearchSpaceRead
"/workspaces/{workspace_id}/api-access", response_model=WorkspaceRead
)
async def update_search_space_api_access(
search_space_id: int,
body: SearchSpaceApiAccessUpdate,
async def update_workspace_api_access(
workspace_id: int,
body: WorkspaceApiAccessUpdate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
Toggle programmatic API/PAT access for a search space.
Toggle programmatic API/PAT access for a workspace.
Requires API_ACCESS_MANAGE permission.
"""
try:
@ -302,23 +302,23 @@ async def update_search_space_api_access(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.API_ACCESS_MANAGE.value,
"You don't have permission to manage API access for this search space",
"You don't have permission to manage API access for this workspace",
)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
select(Workspace).filter(Workspace.id == workspace_id)
)
db_search_space = result.scalars().first()
db_workspace = result.scalars().first()
if not db_search_space:
raise HTTPException(status_code=404, detail="Search space not found")
if not db_workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
db_search_space.api_access_enabled = body.api_access_enabled
db_workspace.api_access_enabled = body.api_access_enabled
await session.commit()
await session.refresh(db_search_space)
return db_search_space
await session.refresh(db_workspace)
return db_workspace
except HTTPException:
raise
except Exception as e:
@ -328,33 +328,33 @@ async def update_search_space_api_access(
) from e
@router.post("/searchspaces/{search_space_id}/ai-sort")
@router.post("/workspaces/{workspace_id}/ai-sort")
async def trigger_ai_sort(
search_space_id: int,
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""Trigger a full AI file sort for all documents in the search space."""
"""Trigger a full AI file sort for all documents in the workspace."""
try:
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.SETTINGS_UPDATE.value,
"You don't have permission to trigger AI sort on this search space",
"You don't have permission to trigger AI sort on this workspace",
)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
select(Workspace).filter(Workspace.id == workspace_id)
)
db_search_space = result.scalars().first()
if not db_search_space:
raise HTTPException(status_code=404, detail="Search space not found")
db_workspace = result.scalars().first()
if not db_workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
from app.tasks.celery_tasks.document_tasks import ai_sort_search_space_task
from app.tasks.celery_tasks.document_tasks import ai_sort_workspace_task
ai_sort_search_space_task.delay(search_space_id, str(user.id))
ai_sort_workspace_task.delay(workspace_id, str(user.id))
return {"message": "AI sort started"}
except HTTPException:
raise
@ -365,14 +365,14 @@ async def trigger_ai_sort(
) from e
@router.delete("/searchspaces/{search_space_id}", response_model=dict)
async def delete_search_space(
search_space_id: int,
@router.delete("/workspaces/{workspace_id}", response_model=dict)
async def delete_workspace(
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
Delete a search space.
Delete a workspace.
Requires SETTINGS_DELETE permission (only owners have this by default).
Heavy cascade deletion (documents, chunks, threads, etc.) is dispatched
@ -383,74 +383,74 @@ async def delete_search_space(
await check_permission(
session,
auth,
search_space_id,
workspace_id,
Permission.SETTINGS_DELETE.value,
"You don't have permission to delete this search space",
"You don't have permission to delete this workspace",
)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
select(Workspace).filter(Workspace.id == workspace_id)
)
db_search_space = result.scalars().first()
db_workspace = result.scalars().first()
if not db_search_space:
raise HTTPException(status_code=404, detail="Search space not found")
if not db_workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
if (db_search_space.name or "").startswith("[DELETING] "):
if (db_workspace.name or "").startswith("[DELETING] "):
raise HTTPException(
status_code=409,
detail="Search space is already being deleted.",
detail="Workspace is already being deleted.",
)
# Soft-delete marker (length-safe for String(100)) so users see pending state.
prefix = "[DELETING] "
max_len = 100
available = max_len - len(prefix)
base_name = db_search_space.name or ""
db_search_space.name = f"{prefix}{base_name[:available]}"
base_name = db_workspace.name or ""
db_workspace.name = f"{prefix}{base_name[:available]}"
await session.commit()
# Dispatch durable background deletion via Celery.
# If queue dispatch fails, revert name to avoid stuck "[DELETING]" state.
try:
from app.tasks.celery_tasks.document_tasks import delete_search_space_task
from app.tasks.celery_tasks.document_tasks import delete_workspace_task
delete_search_space_task.delay(search_space_id)
delete_workspace_task.delay(workspace_id)
except Exception as dispatch_error:
db_search_space.name = base_name
db_workspace.name = base_name
await session.commit()
raise HTTPException(
status_code=503,
detail="Failed to queue background deletion. Please try again.",
) from dispatch_error
return {"message": "Search space deleted successfully"}
return {"message": "Workspace deleted successfully"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to delete search space: {e!s}"
status_code=500, detail=f"Failed to delete workspace: {e!s}"
) from e
@router.get("/searchspaces/{search_space_id}/snapshots")
async def list_search_space_snapshots(
search_space_id: int,
@router.get("/workspaces/{workspace_id}/snapshots")
async def list_workspace_snapshots(
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
List all public chat snapshots for a search space.
List all public chat snapshots for a workspace.
Requires PUBLIC_SHARING_VIEW permission.
"""
from app.schemas.new_chat import PublicChatSnapshotsBySpaceResponse
from app.services.public_chat_service import list_snapshots_for_search_space
from app.services.public_chat_service import list_snapshots_for_workspace
snapshots = await list_snapshots_for_search_space(
snapshots = await list_snapshots_for_workspace(
session=session,
search_space_id=search_space_id,
workspace_id=workspace_id,
auth=auth,
)
return PublicChatSnapshotsBySpaceResponse(snapshots=snapshots)