feat: add search space API access controls

This commit is contained in:
Anish Sarkar 2026-06-19 20:27:06 +05:30
parent 608facd7e7
commit 54a3ba122e
2 changed files with 87 additions and 14 deletions

View file

@ -5,6 +5,7 @@ from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.auth.context import AuthContext
from app.db import ( from app.db import (
Permission, Permission,
SearchSpace, SearchSpace,
@ -15,12 +16,13 @@ from app.db import (
get_default_roles_config, get_default_roles_config,
) )
from app.schemas import ( from app.schemas import (
SearchSpaceApiAccessUpdate,
SearchSpaceCreate, SearchSpaceCreate,
SearchSpaceRead, SearchSpaceRead,
SearchSpaceUpdate, SearchSpaceUpdate,
SearchSpaceWithStats, SearchSpaceWithStats,
) )
from app.users import current_active_user from app.users import get_auth_context
from app.utils.rbac import check_permission, check_search_space_access from app.utils.rbac import check_permission, check_search_space_access
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -74,8 +76,9 @@ async def create_default_roles_and_membership(
async def create_search_space( async def create_search_space(
search_space: SearchSpaceCreate, search_space: SearchSpaceCreate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
try: try:
search_space_data = search_space.model_dump() search_space_data = search_space.model_dump()
@ -108,8 +111,9 @@ async def read_search_spaces(
limit: int = 200, limit: int = 200,
owned_only: bool = False, owned_only: bool = False,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Get all search spaces the user has access to, with member count and ownership info. Get all search spaces the user has access to, with member count and ownership info.
@ -123,11 +127,17 @@ async def read_search_spaces(
# Exclude spaces that are pending background deletion # Exclude spaces that are pending background deletion
not_deleting = ~SearchSpace.name.startswith("[DELETING] ") not_deleting = ~SearchSpace.name.startswith("[DELETING] ")
api_access_filter = (
SearchSpace.api_access_enabled == True # noqa: E712
if auth.is_gated
else True
)
if owned_only: if owned_only:
# Return only search spaces where user is the original creator (user_id) # Return only search spaces where user is the original creator (user_id)
result = await session.execute( result = await session.execute(
select(SearchSpace) select(SearchSpace)
.filter(SearchSpace.user_id == user.id, not_deleting) .filter(SearchSpace.user_id == user.id, not_deleting, api_access_filter)
.order_by(SearchSpace.id.asc()) .order_by(SearchSpace.id.asc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
@ -137,7 +147,11 @@ async def read_search_spaces(
result = await session.execute( result = await session.execute(
select(SearchSpace) select(SearchSpace)
.join(SearchSpaceMembership) .join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id, not_deleting) .filter(
SearchSpaceMembership.user_id == user.id,
not_deleting,
api_access_filter,
)
.order_by(SearchSpace.id.asc()) .order_by(SearchSpace.id.asc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
@ -174,6 +188,7 @@ async def read_search_spaces(
created_at=space.created_at, created_at=space.created_at,
user_id=space.user_id, user_id=space.user_id,
citations_enabled=space.citations_enabled, citations_enabled=space.citations_enabled,
api_access_enabled=space.api_access_enabled,
qna_custom_instructions=space.qna_custom_instructions, qna_custom_instructions=space.qna_custom_instructions,
ai_file_sort_enabled=space.ai_file_sort_enabled, ai_file_sort_enabled=space.ai_file_sort_enabled,
member_count=member_count, member_count=member_count,
@ -192,15 +207,16 @@ async def read_search_spaces(
async def read_search_space( async def read_search_space(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Get a specific search space by ID. Get a specific search space by ID.
Requires SETTINGS_VIEW permission or membership. Requires SETTINGS_VIEW permission or membership.
""" """
try: try:
# Check if user has access (is a member) # Check if user has access (is a member)
await check_search_space_access(session, user, search_space_id) await check_search_space_access(session, auth, search_space_id)
result = await session.execute( result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id) select(SearchSpace).filter(SearchSpace.id == search_space_id)
@ -225,8 +241,9 @@ async def update_search_space(
search_space_id: int, search_space_id: int,
search_space_update: SearchSpaceUpdate, search_space_update: SearchSpaceUpdate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Update a search space. Update a search space.
Requires SETTINGS_UPDATE permission. Requires SETTINGS_UPDATE permission.
@ -235,7 +252,7 @@ async def update_search_space(
# Check permission # Check permission
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.SETTINGS_UPDATE.value, Permission.SETTINGS_UPDATE.value,
"You don't have permission to update this search space", "You don't have permission to update this search space",
@ -265,17 +282,66 @@ async def update_search_space(
) from e ) from e
@router.put("/searchspaces/{search_space_id}/api-access", response_model=SearchSpaceRead)
async def update_search_space_api_access(
search_space_id: int,
body: SearchSpaceApiAccessUpdate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Toggle programmatic API/PAT access for a search space.
Requires API_ACCESS_MANAGE permission.
"""
try:
if not auth.is_session:
raise HTTPException(
status_code=403,
detail="This action requires an interactive session",
)
await check_permission(
session,
auth,
search_space_id,
Permission.API_ACCESS_MANAGE.value,
"You don't have permission to manage API access for this search space",
)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
db_search_space = result.scalars().first()
if not db_search_space:
raise HTTPException(status_code=404, detail="Search space not found")
db_search_space.api_access_enabled = body.api_access_enabled
await session.commit()
await session.refresh(db_search_space)
return db_search_space
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to update API access: {e!s}"
) from e
@router.post("/searchspaces/{search_space_id}/ai-sort") @router.post("/searchspaces/{search_space_id}/ai-sort")
async def trigger_ai_sort( async def trigger_ai_sort(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), 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 search space."""
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.SETTINGS_UPDATE.value, 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 search space",
@ -305,8 +371,9 @@ async def trigger_ai_sort(
async def delete_search_space( async def delete_search_space(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Delete a search space. Delete a search space.
Requires SETTINGS_DELETE permission (only owners have this by default). Requires SETTINGS_DELETE permission (only owners have this by default).
@ -318,7 +385,7 @@ async def delete_search_space(
# Check permission - only those with SETTINGS_DELETE can delete # Check permission - only those with SETTINGS_DELETE can delete
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.SETTINGS_DELETE.value, Permission.SETTINGS_DELETE.value,
"You don't have permission to delete this search space", "You don't have permission to delete this search space",
@ -374,8 +441,9 @@ async def delete_search_space(
async def list_search_space_snapshots( async def list_search_space_snapshots(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
List all public chat snapshots for a search space. List all public chat snapshots for a search space.

View file

@ -24,11 +24,16 @@ class SearchSpaceUpdate(BaseModel):
ai_file_sort_enabled: bool | None = None ai_file_sort_enabled: bool | None = None
class SearchSpaceApiAccessUpdate(BaseModel):
api_access_enabled: bool
class SearchSpaceRead(SearchSpaceBase, IDModel, TimestampModel): class SearchSpaceRead(SearchSpaceBase, IDModel, TimestampModel):
id: int id: int
created_at: datetime created_at: datetime
user_id: uuid.UUID user_id: uuid.UUID
citations_enabled: bool citations_enabled: bool
api_access_enabled: bool = False
qna_custom_instructions: str | None = None qna_custom_instructions: str | None = None
shared_memory_md: str | None = None shared_memory_md: str | None = None
ai_file_sort_enabled: bool = False ai_file_sort_enabled: bool = False