refactor(backend): rename search_space -> workspace across app bulk (Phase 2 Wave D)

Scoped codemod over surfsense_backend/app (excluding routes/, Wave E): renames
search_space_id -> workspace_id, search_space -> workspace, SearchSpace -> Workspace
across services, utils, tasks, agents, gateway, event_bus, notifications, podcasts,
automations, observability params, and prompt .md files. Also flips the camelCase
payload key searchSpaceId -> workspaceId (no backend reader; hard cutover).

Preserved carve-outs (verbatim): Celery task names "delete_search_space_background"
and "ai_sort_search_space" (wire names), and the OTel/metric key "search_space.id"
(dashboards depend on it). Enum values 'SEARCH_SPACE' and SearchSourceConnector
untouched.
This commit is contained in:
CREDO23 2026-06-26 18:30:47 +02:00
parent 0c53d884eb
commit 7fb0707933
259 changed files with 1996 additions and 1996 deletions

View file

@ -118,14 +118,14 @@ def generate_connector_name_with_identifier(
async def count_connectors_of_type(
session: AsyncSession,
connector_type: SearchSourceConnectorType,
search_space_id: int,
workspace_id: int,
user_id: UUID,
) -> int:
"""Count existing connectors of a type for a user in a search space."""
"""Count existing connectors of a type for a user in a workspace."""
result = await session.execute(
select(func.count(SearchSourceConnector.id)).where(
SearchSourceConnector.connector_type == connector_type,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
)
)
@ -135,7 +135,7 @@ async def count_connectors_of_type(
async def check_duplicate_connector(
session: AsyncSession,
connector_type: SearchSourceConnectorType,
search_space_id: int,
workspace_id: int,
user_id: UUID,
identifier: str | None,
) -> bool:
@ -145,7 +145,7 @@ async def check_duplicate_connector(
Args:
session: Database session
connector_type: The type of connector
search_space_id: The search space ID
workspace_id: The workspace ID
user_id: The user ID
identifier: User identifier (email, workspace name, etc.)
@ -159,7 +159,7 @@ async def check_duplicate_connector(
result = await session.execute(
select(func.count(SearchSourceConnector.id)).where(
SearchSourceConnector.connector_type == connector_type,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.name == expected_name,
)
@ -170,11 +170,11 @@ async def check_duplicate_connector(
async def ensure_unique_connector_name(
session: AsyncSession,
name: str,
search_space_id: int,
workspace_id: int,
user_id: UUID,
) -> str:
"""
Ensure a connector name is unique within a user's search space.
Ensure a connector name is unique within a user's workspace.
If the name already exists, appends a counter suffix: (2), (3), etc.
Uses the same suffix format as generate_unique_connector_name.
@ -182,7 +182,7 @@ async def ensure_unique_connector_name(
Args:
session: Database session
name: Desired connector name
search_space_id: The search space ID
workspace_id: The workspace ID
user_id: The user ID
Returns:
@ -190,7 +190,7 @@ async def ensure_unique_connector_name(
"""
result = await session.execute(
select(SearchSourceConnector.name).where(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
)
)
@ -208,7 +208,7 @@ async def ensure_unique_connector_name(
async def generate_unique_connector_name(
session: AsyncSession,
connector_type: SearchSourceConnectorType,
search_space_id: int,
workspace_id: int,
user_id: UUID,
identifier: str | None = None,
) -> str:
@ -221,7 +221,7 @@ async def generate_unique_connector_name(
Args:
session: Database session
connector_type: The type of connector
search_space_id: The search space ID
workspace_id: The workspace ID
user_id: The user ID
identifier: Optional user identifier (email, workspace name, etc.)
@ -235,12 +235,12 @@ async def generate_unique_connector_name(
return await ensure_unique_connector_name(
session,
name,
search_space_id,
workspace_id,
user_id,
)
count = await count_connectors_of_type(
session, connector_type, search_space_id, user_id
session, connector_type, workspace_id, user_id
)
if count == 0:

View file

@ -257,28 +257,28 @@ async def convert_document_to_markdown(elements):
return "".join(markdown_parts)
def generate_content_hash(content: str, search_space_id: int) -> str:
"""Generate SHA-256 hash for the given content combined with search space ID."""
combined_data = f"{search_space_id}:{content}"
def generate_content_hash(content: str, workspace_id: int) -> str:
"""Generate SHA-256 hash for the given content combined with workspace ID."""
combined_data = f"{workspace_id}:{content}"
return hashlib.sha256(combined_data.encode("utf-8")).hexdigest()
def generate_unique_identifier_hash(
document_type: DocumentType,
unique_identifier: str | int | float,
search_space_id: int,
workspace_id: int,
) -> str:
"""
Generate SHA-256 hash for a unique document identifier from connector sources.
This function creates a consistent hash based on the document type, its unique
identifier from the source system, and the search space ID. This helps prevent
identifier from the source system, and the workspace ID. This helps prevent
duplicate documents when syncing from various connectors like Slack, Notion, Jira, etc.
Args:
document_type: The type of document (e.g., SLACK_CONNECTOR, NOTION_CONNECTOR)
unique_identifier: The unique ID from the source system (e.g., message ID, page ID)
search_space_id: The search space this document belongs to
workspace_id: The workspace this document belongs to
Returns:
str: SHA-256 hash string representing the unique document identifier
@ -294,7 +294,7 @@ def generate_unique_identifier_hash(
# Convert unique_identifier to string to handle different types
identifier_str = str(unique_identifier)
# Combine document type value, unique identifier, and search space ID
combined_data = f"{document_type.value}:{identifier_str}:{search_space_id}"
# Combine document type value, unique identifier, and workspace ID
combined_data = f"{document_type.value}:{identifier_str}:{workspace_id}"
return hashlib.sha256(combined_data.encode("utf-8")).hexdigest()

View file

@ -63,7 +63,7 @@ class OAuthStateManager:
Generate cryptographically signed state parameter.
Args:
space_id: The search space ID
space_id: The workspace ID
user_id: The user ID
**extra_fields: Additional fields to include in state (e.g., code_verifier for PKCE)

View file

@ -29,7 +29,7 @@ CONNECTOR_TASK_MAP = {
def create_periodic_schedule(
connector_id: int,
search_space_id: int,
workspace_id: int,
user_id: str,
connector_type: SearchSourceConnectorType,
frequency_minutes: int,
@ -44,7 +44,7 @@ def create_periodic_schedule(
Args:
connector_id: ID of the connector
search_space_id: ID of the search space
workspace_id: ID of the workspace
user_id: User ID
connector_type: Type of connector
frequency_minutes: Frequency in minutes (used for logging)
@ -94,7 +94,7 @@ def create_periodic_schedule(
# Trigger the first run immediately
task = task_map.get(connector_type)
if task:
task.delay(connector_id, search_space_id, user_id, None, None)
task.delay(connector_id, workspace_id, user_id, None, None)
logger.info(
f"✓ First indexing run triggered for connector {connector_id}. "
f"Periodic indexing will continue automatically every {frequency_minutes} minutes."
@ -133,7 +133,7 @@ def delete_periodic_schedule(connector_id: int) -> bool:
def update_periodic_schedule(
connector_id: int,
search_space_id: int,
workspace_id: int,
user_id: str,
connector_type: SearchSourceConnectorType,
frequency_minutes: int,
@ -146,7 +146,7 @@ def update_periodic_schedule(
Args:
connector_id: ID of the connector
search_space_id: ID of the search space
workspace_id: ID of the workspace
user_id: User ID
connector_type: Type of connector
frequency_minutes: New frequency in minutes
@ -160,5 +160,5 @@ def update_periodic_schedule(
)
# Optionally trigger an immediate run with the new schedule
# Uncomment the line below if you want immediate execution on schedule update
# return create_periodic_schedule(connector_id, search_space_id, user_id, connector_type, frequency_minutes)
# return create_periodic_schedule(connector_id, workspace_id, user_id, connector_type, frequency_minutes)
return True

View file

@ -1,6 +1,6 @@
"""
RBAC (Role-Based Access Control) utility functions.
Provides helpers for checking user permissions in search spaces.
Provides helpers for checking user permissions in workspaces.
"""
import secrets
@ -14,9 +14,9 @@ from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
from app.db import (
Permission,
SearchSpace,
SearchSpaceMembership,
SearchSpaceRole,
Workspace,
WorkspaceMembership,
WorkspaceRole,
has_permission,
)
@ -24,25 +24,25 @@ from app.db import (
async def get_user_membership(
session: AsyncSession,
user_id: UUID,
search_space_id: int,
) -> SearchSpaceMembership | None:
workspace_id: int,
) -> WorkspaceMembership | None:
"""
Get the user's membership in a search space.
Get the user's membership in a workspace.
Args:
session: Database session
user_id: User UUID
search_space_id: Search space ID
workspace_id: Workspace ID
Returns:
SearchSpaceMembership if found, None otherwise
WorkspaceMembership if found, None otherwise
"""
result = await session.execute(
select(SearchSpaceMembership)
.options(selectinload(SearchSpaceMembership.role))
select(WorkspaceMembership)
.options(selectinload(WorkspaceMembership.role))
.filter(
SearchSpaceMembership.user_id == user_id,
SearchSpaceMembership.search_space_id == search_space_id,
WorkspaceMembership.user_id == user_id,
WorkspaceMembership.workspace_id == workspace_id,
)
)
return result.scalars().first()
@ -51,20 +51,20 @@ async def get_user_membership(
async def get_user_permissions(
session: AsyncSession,
user_id: UUID,
search_space_id: int,
workspace_id: int,
) -> list[str]:
"""
Get the user's permissions in a search space.
Get the user's permissions in a workspace.
Args:
session: Database session
user_id: User UUID
search_space_id: Search space ID
workspace_id: Workspace ID
Returns:
List of permission strings
"""
membership = await get_user_membership(session, user_id, search_space_id)
membership = await get_user_membership(session, user_id, workspace_id)
if not membership:
return []
@ -84,19 +84,19 @@ async def get_allowed_read_space_ids(
session: AsyncSession,
auth: AuthContext,
) -> list[int]:
"""Return search spaces the principal may read through sync transports.
"""Return workspaces the principal may read through sync transports.
This mirrors the basic REST search-space access rule: membership is required,
This mirrors the basic REST workspace access rule: membership is required,
and PAT principals are additionally constrained by the per-space API gate.
"""
stmt = (
select(SearchSpaceMembership.search_space_id)
.join(SearchSpace, SearchSpace.id == SearchSpaceMembership.search_space_id)
.filter(SearchSpaceMembership.user_id == auth.user.id)
.order_by(SearchSpaceMembership.search_space_id)
select(WorkspaceMembership.workspace_id)
.join(Workspace, Workspace.id == WorkspaceMembership.workspace_id)
.filter(WorkspaceMembership.user_id == auth.user.id)
.order_by(WorkspaceMembership.workspace_id)
)
if auth.is_gated:
stmt = stmt.filter(SearchSpace.api_access_enabled == True) # noqa: E712
stmt = stmt.filter(Workspace.api_access_enabled == True) # noqa: E712
result = await session.execute(stmt)
return list(result.scalars().all())
@ -105,57 +105,57 @@ async def get_allowed_read_space_ids(
async def _enforce_api_access_gate(
session: AsyncSession,
auth: AuthContext,
search_space_id: int,
search_space: SearchSpace | None = None,
) -> SearchSpace:
if search_space is None:
workspace_id: int,
workspace: Workspace | None = None,
) -> Workspace:
if workspace is None:
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")
if auth.is_gated and not search_space.api_access_enabled:
if auth.is_gated and not workspace.api_access_enabled:
raise HTTPException(
status_code=403,
detail="API access is not enabled for this search space.",
detail="API access is not enabled for this workspace.",
)
return search_space
return workspace
async def check_permission(
session: AsyncSession,
auth: AuthContext,
search_space_id: int,
workspace_id: int,
required_permission: str,
error_message: str = "You don't have permission to perform this action",
) -> SearchSpaceMembership:
) -> WorkspaceMembership:
"""
Check if a user has a specific permission in a search space.
Check if a user has a specific permission in a workspace.
Raises HTTPException if permission is denied.
Args:
session: Database session
user: User object
search_space_id: Search space ID
workspace_id: Workspace ID
required_permission: Permission string to check
error_message: Custom error message for permission denied
Returns:
SearchSpaceMembership if permission granted
WorkspaceMembership if permission granted
Raises:
HTTPException: If user doesn't have access or permission
"""
membership = await get_user_membership(session, auth.user.id, search_space_id)
membership = await get_user_membership(session, auth.user.id, workspace_id)
if not membership:
raise HTTPException(
status_code=403,
detail="You don't have access to this search space",
detail="You don't have access to this workspace",
)
# Get user's permissions
@ -169,110 +169,110 @@ async def check_permission(
if not has_permission(permissions, required_permission):
raise HTTPException(status_code=403, detail=error_message)
await _enforce_api_access_gate(session, auth, search_space_id)
await _enforce_api_access_gate(session, auth, workspace_id)
return membership
async def check_search_space_access(
async def check_workspace_access(
session: AsyncSession,
auth: AuthContext,
search_space_id: int,
) -> SearchSpaceMembership:
workspace_id: int,
) -> WorkspaceMembership:
"""
Check if a user has any access to a search space.
Check if a user has any access to a workspace.
This is used for basic access control (user is a member).
Args:
session: Database session
user: User object
search_space_id: Search space ID
workspace_id: Workspace ID
Returns:
SearchSpaceMembership if user has access
WorkspaceMembership if user has access
Raises:
HTTPException: If user doesn't have access
"""
membership = await get_user_membership(session, auth.user.id, search_space_id)
membership = await get_user_membership(session, auth.user.id, workspace_id)
if not membership:
raise HTTPException(
status_code=403,
detail="You don't have access to this search space",
detail="You don't have access to this workspace",
)
await _enforce_api_access_gate(session, auth, search_space_id)
await _enforce_api_access_gate(session, auth, workspace_id)
return membership
async def is_search_space_owner(
async def is_workspace_owner(
session: AsyncSession,
user_id: UUID,
search_space_id: int,
workspace_id: int,
) -> bool:
"""
Check if a user is the owner of a search space.
Check if a user is the owner of a workspace.
Args:
session: Database session
user_id: User UUID
search_space_id: Search space ID
workspace_id: Workspace ID
Returns:
True if user is the owner, False otherwise
"""
membership = await get_user_membership(session, user_id, search_space_id)
membership = await get_user_membership(session, user_id, workspace_id)
return membership is not None and membership.is_owner
async def get_search_space_with_access_check(
async def get_workspace_with_access_check(
session: AsyncSession,
auth: AuthContext,
search_space_id: int,
workspace_id: int,
required_permission: str | None = None,
) -> tuple[SearchSpace, SearchSpaceMembership]:
) -> tuple[Workspace, WorkspaceMembership]:
"""
Get a search space with access and optional permission check.
Get a workspace with access and optional permission check.
Args:
session: Database session
user: User object
search_space_id: Search space ID
workspace_id: Workspace ID
required_permission: Optional permission to check
Returns:
Tuple of (SearchSpace, SearchSpaceMembership)
Tuple of (Workspace, WorkspaceMembership)
Raises:
HTTPException: If search space not found or user lacks access/permission
HTTPException: If workspace not found or user lacks access/permission
"""
# Get the search space
# Get the workspace
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")
# Check access
if required_permission:
membership = await check_permission(
session, auth, search_space_id, required_permission
session, auth, workspace_id, required_permission
)
else:
membership = await check_search_space_access(session, auth, search_space_id)
membership = await check_workspace_access(session, auth, workspace_id)
await _enforce_api_access_gate(session, auth, search_space_id, search_space)
await _enforce_api_access_gate(session, auth, workspace_id, workspace)
return search_space, membership
return workspace, membership
def generate_invite_code() -> str:
"""
Generate a unique invite code for search space invites.
Generate a unique invite code for workspace invites.
Returns:
A 32-character URL-safe invite code
@ -282,22 +282,22 @@ def generate_invite_code() -> str:
async def get_default_role(
session: AsyncSession,
search_space_id: int,
) -> SearchSpaceRole | None:
workspace_id: int,
) -> WorkspaceRole | None:
"""
Get the default role for a search space (used when accepting invites without a specific role).
Get the default role for a workspace (used when accepting invites without a specific role).
Args:
session: Database session
search_space_id: Search space ID
workspace_id: Workspace ID
Returns:
Default SearchSpaceRole or None
Default WorkspaceRole or None
"""
result = 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
)
)
return result.scalars().first()
@ -305,22 +305,22 @@ async def get_default_role(
async def get_owner_role(
session: AsyncSession,
search_space_id: int,
) -> SearchSpaceRole | None:
workspace_id: int,
) -> WorkspaceRole | None:
"""
Get the Owner role for a search space.
Get the Owner role for a workspace.
Args:
session: Database session
search_space_id: Search space ID
workspace_id: Workspace ID
Returns:
Owner SearchSpaceRole or None
Owner WorkspaceRole or None
"""
result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id,
SearchSpaceRole.name == "Owner",
select(WorkspaceRole).filter(
WorkspaceRole.workspace_id == workspace_id,
WorkspaceRole.name == "Owner",
)
)
return result.scalars().first()

View file

@ -13,59 +13,59 @@ import validators
from fastapi import HTTPException
def validate_search_space_id(search_space_id: Any) -> int:
def validate_workspace_id(workspace_id: Any) -> int:
"""
Validate and convert search_space_id to integer.
Validate and convert workspace_id to integer.
Args:
search_space_id: The search space ID to validate
workspace_id: The workspace ID to validate
Returns:
int: Validated search space ID
int: Validated workspace ID
Raises:
HTTPException: If validation fails
"""
if search_space_id is None:
raise HTTPException(status_code=400, detail="search_space_id is required")
if workspace_id is None:
raise HTTPException(status_code=400, detail="workspace_id is required")
if isinstance(search_space_id, bool):
if isinstance(workspace_id, bool):
raise HTTPException(
status_code=400, detail="search_space_id must be an integer, not a boolean"
status_code=400, detail="workspace_id must be an integer, not a boolean"
)
if isinstance(search_space_id, int):
if search_space_id <= 0:
if isinstance(workspace_id, int):
if workspace_id <= 0:
raise HTTPException(
status_code=400, detail="search_space_id must be a positive integer"
status_code=400, detail="workspace_id must be a positive integer"
)
return search_space_id
return workspace_id
if isinstance(search_space_id, str):
if isinstance(workspace_id, str):
# Check if it's a valid integer string
if not search_space_id.strip():
if not workspace_id.strip():
raise HTTPException(
status_code=400, detail="search_space_id cannot be empty"
status_code=400, detail="workspace_id cannot be empty"
)
# Check for valid integer format (no leading zeros, no decimal points)
if not re.match(r"^[1-9]\d*$", search_space_id.strip()):
if not re.match(r"^[1-9]\d*$", workspace_id.strip()):
raise HTTPException(
status_code=400,
detail="search_space_id must be a valid positive integer",
detail="workspace_id must be a valid positive integer",
)
value = int(search_space_id.strip())
value = int(workspace_id.strip())
# Regex already guarantees value > 0, but check retained for clarity
if value <= 0:
raise HTTPException(
status_code=400, detail="search_space_id must be a positive integer"
status_code=400, detail="workspace_id must be a positive integer"
)
return value
raise HTTPException(
status_code=400,
detail="search_space_id must be an integer or string representation of an integer",
detail="workspace_id must be an integer or string representation of an integer",
)