diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts
index 08918e2af..c62b39074 100644
--- a/surfsense_web/contracts/types/search-space.types.ts
+++ b/surfsense_web/contracts/types/search-space.types.ts
@@ -8,6 +8,7 @@ export const searchSpace = z.object({
created_at: z.string(),
user_id: z.string(),
citations_enabled: z.boolean(),
+ api_access_enabled: z.boolean().optional().default(false),
qna_custom_instructions: z.string().nullable(),
shared_memory_md: z.string().nullable().optional(),
ai_file_sort_enabled: z.boolean().optional().default(false),
@@ -55,6 +56,7 @@ export const updateSearchSpaceRequest = z.object({
name: true,
description: true,
citations_enabled: true,
+ api_access_enabled: true,
qna_custom_instructions: true,
ai_file_sort_enabled: true,
})
@@ -63,6 +65,16 @@ export const updateSearchSpaceRequest = z.object({
export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
+export const updateSearchSpaceApiAccessRequest = z.object({
+ id: z.number(),
+ api_access_enabled: z.boolean(),
+});
+
+export const updateSearchSpaceApiAccessResponse = searchSpace.omit({
+ member_count: true,
+ is_owner: true,
+});
+
/**
* Delete search space
*/
@@ -89,5 +101,7 @@ export type GetSearchSpaceRequest = z.infer
;
export type GetSearchSpaceResponse = z.infer;
export type UpdateSearchSpaceRequest = z.infer;
export type UpdateSearchSpaceResponse = z.infer;
+export type UpdateSearchSpaceApiAccessRequest = z.infer;
+export type UpdateSearchSpaceApiAccessResponse = z.infer;
export type DeleteSearchSpaceRequest = z.infer;
export type DeleteSearchSpaceResponse = z.infer;
diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts
index e593245f8..a3966634b 100644
--- a/surfsense_web/lib/apis/search-spaces-api.service.ts
+++ b/surfsense_web/lib/apis/search-spaces-api.service.ts
@@ -14,7 +14,10 @@ import {
getSearchSpacesResponse,
leaveSearchSpaceResponse,
type UpdateSearchSpaceRequest,
+ type UpdateSearchSpaceApiAccessRequest,
updateSearchSpaceRequest,
+ updateSearchSpaceApiAccessRequest,
+ updateSearchSpaceApiAccessResponse,
updateSearchSpaceResponse,
} from "@/contracts/types/search-space.types";
import { ValidationError } from "../error";
@@ -102,6 +105,24 @@ class SearchSpacesApiService {
});
};
+ updateSearchSpaceApiAccess = async (request: UpdateSearchSpaceApiAccessRequest) => {
+ const parsedRequest = updateSearchSpaceApiAccessRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.put(
+ `/api/v1/searchspaces/${request.id}/api-access`,
+ updateSearchSpaceApiAccessResponse,
+ {
+ body: { api_access_enabled: parsedRequest.data.api_access_enabled },
+ }
+ );
+ };
+
/**
* Delete a search space
*/
From 7e8d26fa813ff0d180db6770a24e959a5c8a153d Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:27:28 +0530
Subject: [PATCH 07/29] refactor: route authorization through auth context
---
.../app/gateway/auth_invariant.py | 7 +-
.../app/routes/agent_permissions_route.py | 20 +++--
surfsense_backend/app/routes/rbac_routes.py | 79 ++++++++++++-------
surfsense_backend/app/utils/rbac.py | 46 +++++++++--
4 files changed, 105 insertions(+), 47 deletions(-)
diff --git a/surfsense_backend/app/gateway/auth_invariant.py b/surfsense_backend/app/gateway/auth_invariant.py
index e72023ce1..008250957 100644
--- a/surfsense_backend/app/gateway/auth_invariant.py
+++ b/surfsense_backend/app/gateway/auth_invariant.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import ExternalChatBinding, Permission, User
from app.gateway.bindings import suspend_binding
from app.observability.metrics import record_gateway_auth_invariant_failure
@@ -39,11 +40,13 @@ async def assert_authorization_invariant(
if user is None:
await _fail(session, binding, "owner_missing")
+ auth = AuthContext.system(user, source="gateway")
+
try:
- await check_search_space_access(session, user, binding.search_space_id)
+ await check_search_space_access(session, auth, binding.search_space_id)
await check_permission(
session,
- user,
+ auth,
binding.search_space_id,
Permission.CHATS_CREATE.value,
"External chat owner no longer has permission to chat in this search space",
diff --git a/surfsense_backend/app/routes/agent_permissions_route.py b/surfsense_backend/app/routes/agent_permissions_route.py
index 0c07eeb9c..521adfb03 100644
--- a/surfsense_backend/app/routes/agent_permissions_route.py
+++ b/surfsense_backend/app/routes/agent_permissions_route.py
@@ -30,6 +30,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.agents.chat.multi_agent_chat.shared.feature_flags import get_flags
from app.db import (
AgentPermissionRule,
@@ -39,7 +40,7 @@ from app.db import (
User,
get_async_session,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
logger = logging.getLogger(__name__)
@@ -133,15 +134,16 @@ def _to_read(row: AgentPermissionRule) -> AgentPermissionRuleRead:
async def _ensure_search_space_membership_admin(
- session: AsyncSession, user: User, search_space_id: int
+ session: AsyncSession, auth: AuthContext, search_space_id: int
) -> None:
+ user = auth.user
"""Curating agent rules == "settings" administration on the space."""
space = await session.get(SearchSpace, search_space_id)
if space is None:
raise HTTPException(status_code=404, detail="Search space not found.")
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.SETTINGS_UPDATE.value,
"You don't have permission to manage agent permission rules in this space.",
@@ -160,8 +162,9 @@ async def _ensure_search_space_membership_admin(
async def list_rules(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> list[AgentPermissionRuleRead]:
+ user = auth.user
_flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id)
@@ -183,8 +186,9 @@ async def create_rule(
search_space_id: int,
payload: AgentPermissionRuleCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> AgentPermissionRuleRead:
+ user = auth.user
_flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id)
@@ -232,8 +236,9 @@ async def update_rule(
rule_id: int,
payload: AgentPermissionRuleUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> AgentPermissionRuleRead:
+ user = auth.user
_flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id)
@@ -266,8 +271,9 @@ async def delete_rule(
search_space_id: int,
rule_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> None:
+ user = auth.user
_flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id)
diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py
index 3b91e456d..3d50d589d 100644
--- a/surfsense_backend/app/routes/rbac_routes.py
+++ b/surfsense_backend/app/routes/rbac_routes.py
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
+from app.auth.context import AuthContext
from app.db import (
Permission,
SearchSpace,
@@ -43,7 +44,7 @@ from app.schemas import (
RoleUpdate,
UserSearchSpaceAccess,
)
-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,
@@ -107,6 +108,8 @@ PERMISSION_DESCRIPTIONS = {
"settings:view": "View search space settings",
"settings:update": "Modify search space settings",
"settings:delete": "Delete the entire search space",
+ # API access
+ "api_access:manage": "Enable or disable programmatic API access for a search space",
# Automations
"automations:create": "Create automations from chat or JSON",
"automations:read": "View automations, their triggers, and run history",
@@ -120,8 +123,9 @@ PERMISSION_DESCRIPTIONS = {
@router.get("/permissions", response_model=PermissionsListResponse)
async def list_all_permissions(
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all available permissions that can be assigned to roles.
"""
@@ -156,8 +160,9 @@ async def create_role(
search_space_id: int,
role_data: RoleCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create a new custom role in a search space.
Requires ROLES_CREATE permission.
@@ -165,7 +170,7 @@ async def create_role(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.ROLES_CREATE.value,
"You don't have permission to create roles",
@@ -237,8 +242,9 @@ async def create_role(
async def list_roles(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all roles in a search space.
Requires ROLES_READ permission.
@@ -246,7 +252,7 @@ async def list_roles(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.ROLES_READ.value,
"You don't have permission to view roles",
@@ -275,8 +281,9 @@ async def get_role(
search_space_id: int,
role_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get a specific role by ID.
Requires ROLES_READ permission.
@@ -284,7 +291,7 @@ async def get_role(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.ROLES_READ.value,
"You don't have permission to view roles",
@@ -320,8 +327,9 @@ async def update_role(
role_id: int,
role_update: RoleUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update a role.
Requires ROLES_UPDATE permission.
@@ -330,7 +338,7 @@ async def update_role(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.ROLES_UPDATE.value,
"You don't have permission to update roles",
@@ -417,8 +425,9 @@ async def delete_role(
search_space_id: int,
role_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a custom role.
Requires ROLES_DELETE permission.
@@ -427,7 +436,7 @@ async def delete_role(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.ROLES_DELETE.value,
"You don't have permission to delete roles",
@@ -474,8 +483,9 @@ async def delete_role(
async def list_members(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all members of a search space.
Requires MEMBERS_VIEW permission.
@@ -483,7 +493,7 @@ async def list_members(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.MEMBERS_VIEW.value,
"You don't have permission to view members",
@@ -539,8 +549,9 @@ async def update_member_role(
membership_id: int,
membership_update: MembershipUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update a member's role.
Requires MEMBERS_MANAGE_ROLES permission.
@@ -549,7 +560,7 @@ async def update_member_role(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.MEMBERS_MANAGE_ROLES.value,
"You don't have permission to manage member roles",
@@ -629,8 +640,9 @@ async def update_member_role(
async def leave_search_space(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Leave a search space (remove own membership).
Owners cannot leave their search space.
@@ -675,8 +687,9 @@ async def remove_member(
search_space_id: int,
membership_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Remove a member from a search space.
Requires MEMBERS_REMOVE permission.
@@ -685,7 +698,7 @@ async def remove_member(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.MEMBERS_REMOVE.value,
"You don't have permission to remove members",
@@ -733,8 +746,9 @@ async def create_invite(
search_space_id: int,
invite_data: InviteCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create a new invite link for a search space.
Requires MEMBERS_INVITE permission.
@@ -742,7 +756,7 @@ async def create_invite(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to create invites",
@@ -798,8 +812,9 @@ async def create_invite(
async def list_invites(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all invites for a search space.
Requires MEMBERS_INVITE permission.
@@ -807,7 +822,7 @@ async def list_invites(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to view invites",
@@ -837,8 +852,9 @@ async def update_invite(
invite_id: int,
invite_update: InviteUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update an invite.
Requires MEMBERS_INVITE permission.
@@ -846,7 +862,7 @@ async def update_invite(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to update invites",
@@ -903,8 +919,9 @@ async def revoke_invite(
search_space_id: int,
invite_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Revoke (delete) an invite.
Requires MEMBERS_INVITE permission.
@@ -912,7 +929,7 @@ async def revoke_invite(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to revoke invites",
@@ -1022,8 +1039,9 @@ async def get_invite_info(
async def accept_invite(
request: InviteAcceptRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Accept an invite and join a search space.
"""
@@ -1120,13 +1138,14 @@ async def accept_invite(
async def get_my_access(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get the current user's access info for a search space.
"""
try:
- membership = await check_search_space_access(session, user, search_space_id)
+ membership = await check_search_space_access(session, auth, search_space_id)
# Get search space name
result = await session.execute(
diff --git a/surfsense_backend/app/utils/rbac.py b/surfsense_backend/app/utils/rbac.py
index 6cb180d80..8777f09f6 100644
--- a/surfsense_backend/app/utils/rbac.py
+++ b/surfsense_backend/app/utils/rbac.py
@@ -11,12 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
+from app.auth.context import AuthContext
from app.db import (
Permission,
SearchSpace,
SearchSpaceMembership,
SearchSpaceRole,
- User,
has_permission,
)
@@ -80,9 +80,33 @@ async def get_user_permissions(
return []
+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:
+ result = await session.execute(
+ select(SearchSpace).filter(SearchSpace.id == search_space_id)
+ )
+ search_space = result.scalars().first()
+
+ if not search_space:
+ raise HTTPException(status_code=404, detail="Search space not found")
+
+ if auth.is_gated and not search_space.api_access_enabled:
+ raise HTTPException(
+ status_code=403,
+ detail="API access is not enabled for this search space.",
+ )
+
+ return search_space
+
+
async def check_permission(
session: AsyncSession,
- user: User,
+ auth: AuthContext,
search_space_id: int,
required_permission: str,
error_message: str = "You don't have permission to perform this action",
@@ -104,7 +128,7 @@ async def check_permission(
Raises:
HTTPException: If user doesn't have access or permission
"""
- membership = await get_user_membership(session, user.id, search_space_id)
+ membership = await get_user_membership(session, auth.user.id, search_space_id)
if not membership:
raise HTTPException(
@@ -123,12 +147,14 @@ 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)
+
return membership
async def check_search_space_access(
session: AsyncSession,
- user: User,
+ auth: AuthContext,
search_space_id: int,
) -> SearchSpaceMembership:
"""
@@ -146,7 +172,7 @@ async def check_search_space_access(
Raises:
HTTPException: If user doesn't have access
"""
- membership = await get_user_membership(session, user.id, search_space_id)
+ membership = await get_user_membership(session, auth.user.id, search_space_id)
if not membership:
raise HTTPException(
@@ -154,6 +180,8 @@ async def check_search_space_access(
detail="You don't have access to this search space",
)
+ await _enforce_api_access_gate(session, auth, search_space_id)
+
return membership
@@ -179,7 +207,7 @@ async def is_search_space_owner(
async def get_search_space_with_access_check(
session: AsyncSession,
- user: User,
+ auth: AuthContext,
search_space_id: int,
required_permission: str | None = None,
) -> tuple[SearchSpace, SearchSpaceMembership]:
@@ -210,10 +238,12 @@ async def get_search_space_with_access_check(
# Check access
if required_permission:
membership = await check_permission(
- session, user, search_space_id, required_permission
+ session, auth, search_space_id, required_permission
)
else:
- membership = await check_search_space_access(session, user, search_space_id)
+ membership = await check_search_space_access(session, auth, search_space_id)
+
+ await _enforce_api_access_gate(session, auth, search_space_id, search_space)
return search_space, membership
From 493e8d5a64624bfd861617f307d82dfbeb9abdfe Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:27:47 +0530
Subject: [PATCH 08/29] feat: enforce API access for knowledge resources
---
surfsense_backend/app/file_storage/api.py | 11 +-
.../app/routes/documents_routes.py | 102 +++++++++++-------
surfsense_backend/app/routes/editor_routes.py | 23 ++--
.../app/routes/folders_routes.py | 58 ++++++----
surfsense_backend/app/routes/notes_routes.py | 18 ++--
.../app/routes/reports_routes.py | 31 ++++--
.../routes/search_source_connectors_routes.py | 75 ++++++++-----
.../app/routes/team_memory_routes.py | 18 ++--
8 files changed, 206 insertions(+), 130 deletions(-)
diff --git a/surfsense_backend/app/file_storage/api.py b/surfsense_backend/app/file_storage/api.py
index c649ba63d..fd08a6244 100644
--- a/surfsense_backend/app/file_storage/api.py
+++ b/surfsense_backend/app/file_storage/api.py
@@ -9,6 +9,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import Document, Permission, User, get_async_session
from app.file_storage.persistence.enums import DocumentFileKind
from app.file_storage.schemas import DocumentFileRead
@@ -17,7 +18,7 @@ from app.file_storage.service import (
list_document_files,
open_document_file_stream,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
router = APIRouter()
@@ -35,7 +36,7 @@ async def _load_readable_document(
await check_permission(
session,
- user,
+ auth,
document.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -57,8 +58,9 @@ def _content_disposition(filename: str) -> str:
async def read_document_files(
document_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> list[DocumentFileRead]:
+ user = auth.user
"""Return metadata for every stored file of a document (gates the UI)."""
await _load_readable_document(document_id=document_id, session=session, user=user)
records = await list_document_files(session, document_id=document_id)
@@ -69,8 +71,9 @@ async def read_document_files(
async def download_original_document_file(
document_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> StreamingResponse:
+ user = auth.user
"""Stream the document's original uploaded file."""
await _load_readable_document(document_id=document_id, session=session, user=user)
diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index 53f03a0ca..3991af445 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
+from app.auth.context import AuthContext
from app.agents.chat.runtime.path_resolver import virtual_path_to_doc
from app.db import (
Chunk,
@@ -35,7 +36,7 @@ from app.schemas import (
PaginatedResponse,
)
from app.services.task_dispatcher import TaskDispatcher, get_task_dispatcher
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
try:
@@ -60,8 +61,9 @@ MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 # 500 MB per file
async def create_documents(
request: DocumentsCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create new documents.
Requires DOCUMENTS_CREATE permission.
@@ -70,7 +72,7 @@ async def create_documents(
# Check permission
await check_permission(
session,
- user,
+ auth,
request.search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
@@ -128,9 +130,10 @@ async def create_documents_file_upload(
use_vision_llm: bool = Form(False),
processing_mode: str = Form("basic"),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
dispatcher: TaskDispatcher = Depends(get_task_dispatcher),
):
+ user = auth.user
"""
Upload files as documents with real-time status tracking.
@@ -159,7 +162,7 @@ async def create_documents_file_upload(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
@@ -340,8 +343,9 @@ async def read_documents(
sort_by: str = "created_at",
sort_order: str = "desc",
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List documents the user has access to, with optional filtering and pagination.
Requires DOCUMENTS_READ permission for the search space(s).
@@ -369,7 +373,7 @@ async def read_documents(
if search_space_id is not None:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -519,8 +523,9 @@ async def search_documents(
search_space_id: int | None = None,
document_types: str | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ 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).
@@ -549,7 +554,7 @@ async def search_documents(
if search_space_id is not None:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -677,8 +682,9 @@ async def search_document_titles(
page: int = 0,
page_size: int = 20,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Lightweight document title search optimized for mention picker (@mentions).
@@ -703,7 +709,7 @@ async def search_document_titles(
# Check permission for the search space
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -781,8 +787,9 @@ async def get_document_by_virtual_path(
search_space_id: int,
virtual_path: str,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Resolve a knowledge-base document by its agent-facing virtual path.
The agent renders every document under ``/documents/...`` with a
@@ -804,7 +811,7 @@ async def get_document_by_virtual_path(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -838,8 +845,9 @@ async def get_documents_status(
search_space_id: int,
document_ids: str,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Batch status endpoint for documents in a search space.
@@ -849,7 +857,7 @@ async def get_documents_status(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -905,8 +913,9 @@ async def get_documents_status(
async def get_document_type_counts(
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ 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).
@@ -926,7 +935,7 @@ async def get_document_type_counts(
# Check permission for specific search space
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -965,8 +974,9 @@ async def get_document_by_chunk_id(
5, ge=0, description="Number of chunks before/after the cited chunk to include"
),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Retrieves a document based on a chunk ID, including a window of chunks around the cited one.
Uses SQL-level pagination to avoid loading all chunks into memory.
@@ -995,7 +1005,7 @@ async def get_document_by_chunk_id(
await check_permission(
session,
- user,
+ auth,
document.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -1060,12 +1070,13 @@ async def get_document_by_chunk_id(
async def get_watched_folders(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Return root folders that are marked as watched (metadata->>'watched' = 'true')."""
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -1101,8 +1112,9 @@ async def get_document_chunks_paginated(
None, ge=0, description="Direct offset; overrides page * page_size"
),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Paginated chunk loading for a document.
Supports both page-based and offset-based access.
@@ -1120,7 +1132,7 @@ async def get_document_chunks_paginated(
await check_permission(
session,
- user,
+ auth,
document.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -1162,8 +1174,9 @@ async def get_document_chunks_paginated(
async def read_document(
document_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get a specific document by ID.
Requires DOCUMENTS_READ permission for the search space.
@@ -1182,7 +1195,7 @@ async def read_document(
# Check permission for the search space
await check_permission(
session,
- user,
+ auth,
document.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -1216,8 +1229,9 @@ async def update_document(
document_id: int,
document_update: DocumentUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update a document.
Requires DOCUMENTS_UPDATE permission for the search space.
@@ -1236,7 +1250,7 @@ async def update_document(
# Check permission for the search space
await check_permission(
session,
- user,
+ auth,
db_document.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update documents in this search space",
@@ -1275,8 +1289,9 @@ async def update_document(
async def delete_document(
document_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a document.
Requires DOCUMENTS_DELETE permission for the search space.
@@ -1311,7 +1326,7 @@ async def delete_document(
# Check permission for the search space
await check_permission(
session,
- user,
+ auth,
document.search_space_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete documents in this search space",
@@ -1355,8 +1370,9 @@ async def delete_document(
async def list_document_versions(
document_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""List all versions for a document, ordered by version_number descending."""
document = (
await session.execute(select(Document).where(Document.id == document_id))
@@ -1396,8 +1412,9 @@ async def get_document_version(
document_id: int,
version_number: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Get full version content including source_markdown."""
document = (
await session.execute(select(Document).where(Document.id == document_id))
@@ -1434,8 +1451,9 @@ async def restore_document_version(
document_id: int,
version_number: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Restore a previous version: snapshot current state, then overwrite document content."""
document = (
await session.execute(select(Document).where(Document.id == document_id))
@@ -1517,8 +1535,9 @@ class FolderSyncFinalizeRequest(PydanticBaseModel):
async def folder_mtime_check(
request: FolderMtimeCheckRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Pre-upload optimization: check which files need uploading based on mtime.
Returns the subset of relative paths where the file is new or has a
@@ -1528,7 +1547,7 @@ async def folder_mtime_check(
await check_permission(
session,
- user,
+ auth,
request.search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
@@ -1587,8 +1606,9 @@ async def folder_upload(
use_vision_llm: bool = Form(False),
processing_mode: str = Form("basic"),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Upload files from the desktop app for folder indexing.
Files are written to temp storage and dispatched to a Celery task.
@@ -1603,7 +1623,7 @@ async def folder_upload(
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create documents in this search space",
@@ -1733,8 +1753,9 @@ async def folder_upload(
async def folder_unlink(
request: FolderUnlinkRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Handle file deletion events from the desktop watcher.
For each relative path, find the matching document and delete it.
@@ -1746,7 +1767,7 @@ async def folder_unlink(
await check_permission(
session,
- user,
+ auth,
request.search_space_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete documents in this search space",
@@ -1787,8 +1808,9 @@ async def folder_unlink(
async def folder_sync_finalize(
request: FolderSyncFinalizeRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Finalize a full folder scan by deleting orphaned documents.
The client sends the complete list of relative paths currently in the
@@ -1803,7 +1825,7 @@ async def folder_sync_finalize(
await check_permission(
session,
- user,
+ auth,
request.search_space_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete documents in this search space",
diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py
index 8250fff98..fe00995ea 100644
--- a/surfsense_backend/app/routes/editor_routes.py
+++ b/surfsense_backend/app/routes/editor_routes.py
@@ -18,6 +18,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import Chunk, Document, DocumentType, Permission, User, get_async_session
from app.routes.reports_routes import (
_FILE_EXTENSIONS,
@@ -31,7 +32,7 @@ from app.templates.export_helpers import (
get_reference_docx_path,
get_typst_template_path,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
logger = logging.getLogger(__name__)
@@ -47,8 +48,9 @@ async def get_editor_content(
search_space_id: int,
document_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get document content for editing.
@@ -60,7 +62,7 @@ async def get_editor_content(
# Check RBAC permission
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -178,15 +180,16 @@ async def download_document_markdown(
search_space_id: int,
document_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Download the full document content as a .md file.
Reconstructs markdown from source_markdown or chunks.
"""
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
@@ -244,8 +247,9 @@ async def save_document(
document_id: int,
data: dict[str, Any],
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Save document markdown and trigger reindexing.
Called when user clicks 'Save & Exit'.
@@ -259,7 +263,7 @@ async def save_document(
# Check RBAC permission
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update documents in this search space",
@@ -331,12 +335,13 @@ async def export_document(
description="Export format: pdf, docx, html, latex, epub, odt, or plain",
),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Export a document in the requested format (reuses the report export pipeline)."""
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
diff --git a/surfsense_backend/app/routes/folders_routes.py b/surfsense_backend/app/routes/folders_routes.py
index dca55f31e..8a5dfcb73 100644
--- a/surfsense_backend/app/routes/folders_routes.py
+++ b/surfsense_backend/app/routes/folders_routes.py
@@ -5,6 +5,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
+from app.auth.context import AuthContext
from app.db import Document, Folder, Permission, User, get_async_session
from app.schemas import (
BulkDocumentMove,
@@ -23,7 +24,7 @@ from app.services.folder_service import (
get_subtree_max_depth,
validate_folder_depth,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
router = APIRouter()
@@ -33,13 +34,14 @@ router = APIRouter()
async def create_folder(
request: FolderCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Create a new folder. Requires DOCUMENTS_CREATE permission."""
try:
await check_permission(
session,
- user,
+ auth,
request.search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create folders in this search space",
@@ -91,13 +93,14 @@ async def create_folder(
async def list_folders(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""List all folders in a search space (flat). Requires DOCUMENTS_READ permission."""
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
@@ -122,8 +125,9 @@ async def list_folders(
async def get_folder(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Get a single folder. Requires DOCUMENTS_READ permission."""
try:
folder = await session.get(Folder, folder_id)
@@ -132,7 +136,7 @@ async def get_folder(
await check_permission(
session,
- user,
+ auth,
folder.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
@@ -152,8 +156,9 @@ async def get_folder(
async def get_folder_breadcrumb(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Get ancestor chain for breadcrumb display. Requires DOCUMENTS_READ permission."""
try:
folder = await session.get(Folder, folder_id)
@@ -162,7 +167,7 @@ async def get_folder_breadcrumb(
await check_permission(
session,
- user,
+ auth,
folder.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this search space",
@@ -196,8 +201,9 @@ async def get_folder_breadcrumb(
async def stop_watching_folder(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Clear the watched flag from a folder's metadata."""
folder = await session.get(Folder, folder_id)
if not folder:
@@ -205,7 +211,7 @@ async def stop_watching_folder(
await check_permission(
session,
- user,
+ auth,
folder.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update folders in this search space",
@@ -224,8 +230,9 @@ async def update_folder(
folder_id: int,
request: FolderUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Rename a folder. Requires DOCUMENTS_UPDATE permission."""
try:
folder = await session.get(Folder, folder_id)
@@ -234,7 +241,7 @@ async def update_folder(
await check_permission(
session,
- user,
+ auth,
folder.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update folders in this search space",
@@ -264,8 +271,9 @@ async def move_folder(
folder_id: int,
request: FolderMove,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Move a folder to a new parent. Requires DOCUMENTS_UPDATE permission."""
try:
folder = await session.get(Folder, folder_id)
@@ -274,7 +282,7 @@ async def move_folder(
await check_permission(
session,
- user,
+ auth,
folder.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move folders in this search space",
@@ -324,8 +332,9 @@ async def reorder_folder(
folder_id: int,
request: FolderReorder,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Reorder a folder among its siblings via fractional indexing. Requires DOCUMENTS_UPDATE."""
try:
folder = await session.get(Folder, folder_id)
@@ -334,7 +343,7 @@ async def reorder_folder(
await check_permission(
session,
- user,
+ auth,
folder.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to reorder folders in this search space",
@@ -365,8 +374,9 @@ async def reorder_folder(
async def delete_folder(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Mark documents for deletion and dispatch Celery to delete docs first, then folders."""
try:
folder = await session.get(Folder, folder_id)
@@ -375,7 +385,7 @@ async def delete_folder(
await check_permission(
session,
- user,
+ auth,
folder.search_space_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete folders in this search space",
@@ -439,8 +449,9 @@ async def move_document(
document_id: int,
request: DocumentMove,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Move a document to a folder (or root). Requires DOCUMENTS_UPDATE permission."""
try:
result = await session.execute(
@@ -452,7 +463,7 @@ async def move_document(
await check_permission(
session,
- user,
+ auth,
document.search_space_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move documents in this search space",
@@ -485,8 +496,9 @@ async def move_document(
async def bulk_move_documents(
request: BulkDocumentMove,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Move multiple documents to a folder (or root). Requires DOCUMENTS_UPDATE permission."""
try:
if not request.document_ids:
@@ -504,7 +516,7 @@ async def bulk_move_documents(
for ss_id in search_space_ids:
await check_permission(
session,
- user,
+ auth,
ss_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move documents in this search space",
diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py
index 76518de08..e5cca8700 100644
--- a/surfsense_backend/app/routes/notes_routes.py
+++ b/surfsense_backend/app/routes/notes_routes.py
@@ -9,9 +9,10 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import Document, DocumentType, Permission, User, get_async_session
from app.schemas import DocumentRead, PaginatedResponse
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
router = APIRouter()
@@ -27,8 +28,9 @@ async def create_note(
search_space_id: int,
request: CreateNoteRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create a new note document.
@@ -37,7 +39,7 @@ async def create_note(
# Check RBAC permission
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create notes in this search space",
@@ -98,8 +100,9 @@ async def list_notes(
page: int | None = None,
page_size: int = 50,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all notes in a search space.
@@ -108,7 +111,7 @@ async def list_notes(
# Check RBAC permission
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read notes in this search space",
@@ -191,8 +194,9 @@ async def delete_note(
search_space_id: int,
note_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a note.
@@ -201,7 +205,7 @@ async def delete_note(
# Check RBAC permission
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete notes in this search space",
diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py
index 19961e1a9..d5996485e 100644
--- a/surfsense_backend/app/routes/reports_routes.py
+++ b/surfsense_backend/app/routes/reports_routes.py
@@ -28,6 +28,7 @@ from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import (
Report,
SearchSpace,
@@ -42,7 +43,7 @@ from app.templates.export_helpers import (
get_reference_docx_path,
get_typst_template_path,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_search_space_access
logger = logging.getLogger(__name__)
@@ -158,8 +159,9 @@ def _normalize_latex_delimiters(text: str) -> str:
async def _get_report_with_access(
report_id: int,
session: AsyncSession,
- user: User,
+ auth: AuthContext,
) -> Report:
+ user = auth.user
"""Fetch a report and verify the user belongs to its search space.
Raises HTTPException(404) if not found, HTTPException(403) if no access.
@@ -172,7 +174,7 @@ async def _get_report_with_access(
# 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, user, report.search_space_id)
+ await check_search_space_access(session, auth, report.search_space_id)
return report
@@ -206,8 +208,9 @@ async def read_reports(
limit: int = Query(default=100, ge=1, le=MAX_REPORT_LIST_LIMIT),
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List reports the user has access to.
Filters by search space membership.
@@ -215,7 +218,7 @@ async def read_reports(
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, user, search_space_id)
+ await check_search_space_access(session, auth, search_space_id)
result = await session.execute(
select(Report)
@@ -247,8 +250,9 @@ async def read_reports(
async def read_report(
report_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get a specific report by ID (metadata only, no content).
"""
@@ -266,8 +270,9 @@ async def read_report(
async def read_report_content(
report_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get full Markdown content of a report, including version siblings.
"""
@@ -298,8 +303,9 @@ async def update_report_content(
report_id: int,
body: ReportContentUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update the Markdown content of a report.
@@ -339,8 +345,9 @@ async def update_report_content(
async def preview_report_pdf(
report_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Return a compiled PDF preview for Typst-based reports (resumes).
@@ -394,8 +401,9 @@ async def export_report(
description="Export format: pdf, docx, html, latex, epub, odt, or plain",
),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Export a report in the requested format.
"""
@@ -568,8 +576,9 @@ async def export_report(
async def delete_report(
report_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a report.
"""
diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py
index 512b52ae4..fab79ab49 100644
--- a/surfsense_backend/app/routes/search_source_connectors_routes.py
+++ b/surfsense_backend/app/routes/search_source_connectors_routes.py
@@ -33,6 +33,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
+from app.auth.context import AuthContext
from app.config import config
from app.connectors.github_connector import GitHubConnector
from app.db import (
@@ -56,7 +57,7 @@ from app.schemas import (
SearchSourceConnectorUpdate,
)
from app.services.composio_service import ComposioService, get_composio_service
-from app.users import current_active_user
+from app.users import get_auth_context
# NOTE: connector indexer functions are imported lazily inside each
# ``run_*_indexing`` helper to break a circular import cycle:
@@ -143,8 +144,9 @@ class GitHubPATRequest(BaseModel):
@router.post("/github/repositories", response_model=list[dict[str, Any]])
async def list_github_repositories(
pat_request: GitHubPATRequest,
- user: User = Depends(current_active_user), # Ensure the user is logged in
+ auth: AuthContext = Depends(get_auth_context), # Ensure the user is logged in
):
+ user = auth.user
"""
Fetches a list of repositories accessible by the provided GitHub PAT.
The PAT is used for this request only and is not stored.
@@ -173,8 +175,9 @@ async def create_search_source_connector(
..., description="ID of the search space to associate the connector with"
),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create a new search source connector.
Requires CONNECTORS_CREATE permission.
@@ -186,7 +189,7 @@ async def create_search_source_connector(
# Check if user has permission to create connectors
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.CONNECTORS_CREATE.value,
"You don't have permission to create connectors in this search space",
@@ -281,8 +284,9 @@ async def read_search_source_connectors(
limit: int = 100,
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all search source connectors for a search space.
Requires CONNECTORS_READ permission.
@@ -297,7 +301,7 @@ async def read_search_source_connectors(
# Check if user has permission to read connectors
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to view connectors in this search space",
@@ -324,8 +328,9 @@ async def read_search_source_connectors(
async def read_search_source_connector(
connector_id: int,
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 source connector by ID.
Requires CONNECTORS_READ permission.
@@ -345,7 +350,7 @@ async def read_search_source_connector(
# Check permission
await check_permission(
session,
- user,
+ auth,
connector.search_space_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to view this connector",
@@ -367,8 +372,9 @@ async def update_search_source_connector(
connector_id: int,
connector_update: SearchSourceConnectorUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update a search source connector.
Requires CONNECTORS_UPDATE permission.
@@ -386,7 +392,7 @@ async def update_search_source_connector(
# Check permission
await check_permission(
session,
- user,
+ auth,
db_connector.search_space_id,
Permission.CONNECTORS_UPDATE.value,
"You don't have permission to update this connector",
@@ -557,8 +563,9 @@ async def update_search_source_connector(
async def delete_search_source_connector(
connector_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a search source connector and all its associated documents.
@@ -588,7 +595,7 @@ async def delete_search_source_connector(
# Check permission
await check_permission(
session,
- user,
+ auth,
db_connector.search_space_id,
Permission.CONNECTORS_DELETE.value,
"You don't have permission to delete this connector",
@@ -725,8 +732,9 @@ async def index_connector_content(
description="[Google Drive only] Structured request with folders and files to index",
),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Index content from a KB connector to a search space.
@@ -760,7 +768,7 @@ async def index_connector_content(
# the read/update/delete handlers — not the client-supplied query param.
await check_permission(
session,
- user,
+ auth,
connector.search_space_id,
Permission.CONNECTORS_UPDATE.value,
"You don't have permission to index content in this search space",
@@ -2645,8 +2653,9 @@ async def create_mcp_connector(
connector_data: MCPConnectorCreate,
search_space_id: int = Query(..., description="Search space ID"),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create a new MCP (Model Context Protocol) connector.
@@ -2669,7 +2678,7 @@ async def create_mcp_connector(
# Check user has permission to create connectors
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.CONNECTORS_CREATE.value,
"You don't have permission to create connectors in this search space",
@@ -2724,8 +2733,9 @@ async def create_mcp_connector(
async def list_mcp_connectors(
search_space_id: int = Query(..., description="Search space ID"),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all MCP connectors for a search space.
@@ -2741,7 +2751,7 @@ async def list_mcp_connectors(
# Check user has permission to read connectors
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to view connectors in this search space",
@@ -2775,8 +2785,9 @@ async def list_mcp_connectors(
async def get_mcp_connector(
connector_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get a specific MCP connector by ID.
@@ -2805,7 +2816,7 @@ async def get_mcp_connector(
# Check user has permission to read connectors
await check_permission(
session,
- user,
+ auth,
connector.search_space_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to view this connector",
@@ -2828,8 +2839,9 @@ async def update_mcp_connector(
connector_id: int,
connector_update: MCPConnectorUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update an MCP connector.
@@ -2859,7 +2871,7 @@ async def update_mcp_connector(
# Check user has permission to update connectors
await check_permission(
session,
- user,
+ auth,
connector.search_space_id,
Permission.CONNECTORS_UPDATE.value,
"You don't have permission to update this connector",
@@ -2904,8 +2916,9 @@ async def update_mcp_connector(
async def delete_mcp_connector(
connector_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete an MCP connector.
@@ -2931,7 +2944,7 @@ async def delete_mcp_connector(
# Check user has permission to delete connectors
await check_permission(
session,
- user,
+ auth,
connector.search_space_id,
Permission.CONNECTORS_DELETE.value,
"You don't have permission to delete this connector",
@@ -2962,8 +2975,9 @@ async def delete_mcp_connector(
@router.post("/connectors/mcp/test")
async def test_mcp_server_connection(
server_config: dict = Body(...),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Test connection to an MCP server and fetch available tools.
@@ -3042,8 +3056,9 @@ DRIVE_CONNECTOR_TYPES = {
async def get_drive_picker_token(
connector_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Return an OAuth access token + client ID for the Google Picker API."""
result = await session.execute(
select(SearchSourceConnector).filter(SearchSourceConnector.id == connector_id)
@@ -3054,7 +3069,7 @@ async def get_drive_picker_token(
await check_permission(
session,
- user,
+ auth,
connector.search_space_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to access this connector",
@@ -3164,8 +3179,9 @@ async def trust_mcp_tool(
connector_id: int,
body: MCPTrustToolRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Add a tool to the MCP connector's trusted (always-allow) list.
Once trusted, the tool executes without HITL approval on subsequent
@@ -3209,8 +3225,9 @@ async def untrust_mcp_tool(
connector_id: int,
body: MCPTrustToolRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Remove a tool from the MCP connector's trusted list.
The tool will require HITL approval again on subsequent calls.
diff --git a/surfsense_backend/app/routes/team_memory_routes.py b/surfsense_backend/app/routes/team_memory_routes.py
index b37a99b03..3ded87d36 100644
--- a/surfsense_backend/app/routes/team_memory_routes.py
+++ b/surfsense_backend/app/routes/team_memory_routes.py
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import User, get_async_session
from app.services.memory import (
MemoryRead,
@@ -15,7 +16,7 @@ from app.services.memory import (
reset_memory,
save_memory,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_search_space_access
router = APIRouter()
@@ -29,9 +30,10 @@ class TeamMemoryUpdate(BaseModel):
async def get_team_memory(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
- await check_search_space_access(session, user, search_space_id)
+ user = auth.user
+ await check_search_space_access(session, auth, search_space_id)
memory_md = await read_memory(
scope=MemoryScope.TEAM,
target_id=search_space_id,
@@ -45,9 +47,10 @@ async def update_team_memory(
search_space_id: int,
body: TeamMemoryUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
- await check_search_space_access(session, user, search_space_id)
+ user = auth.user
+ await check_search_space_access(session, auth, search_space_id)
result = await save_memory(
scope=MemoryScope.TEAM,
target_id=search_space_id,
@@ -63,9 +66,10 @@ async def update_team_memory(
async def reset_team_memory(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
- await check_search_space_access(session, user, search_space_id)
+ user = auth.user
+ await check_search_space_access(session, auth, search_space_id)
result = await reset_memory(
scope=MemoryScope.TEAM,
target_id=search_space_id,
From 70a0828b950c58f1d8173c0186d8e914a60b5a10 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:28:02 +0530
Subject: [PATCH 09/29] feat: enforce API access for chat routes
---
.../app/routes/agent_action_log_route.py | 8 +-
.../app/routes/chat_comments_routes.py | 24 +++--
.../app/routes/model_connections_routes.py | 81 +++++++++--------
.../app/routes/new_chat_routes.py | 89 +++++++++++--------
.../app/routes/public_chat_routes.py | 6 +-
.../app/services/chat_comments_service.py | 26 ++++--
.../app/services/public_chat_service.py | 21 +++--
7 files changed, 152 insertions(+), 103 deletions(-)
diff --git a/surfsense_backend/app/routes/agent_action_log_route.py b/surfsense_backend/app/routes/agent_action_log_route.py
index 9a55fdec3..bf94ae3b4 100644
--- a/surfsense_backend/app/routes/agent_action_log_route.py
+++ b/surfsense_backend/app/routes/agent_action_log_route.py
@@ -28,6 +28,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.agents.chat.multi_agent_chat.shared.feature_flags import get_flags
from app.db import (
AgentActionLog,
@@ -36,7 +37,7 @@ from app.db import (
User,
get_async_session,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
logger = logging.getLogger(__name__)
@@ -111,8 +112,9 @@ async def list_thread_actions(
page: int = Query(0, ge=0),
page_size: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> AgentActionListResponse:
+ user = auth.user
"""List agent actions for a thread, newest first.
Authorization:
@@ -132,7 +134,7 @@ async def list_thread_actions(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_READ.value,
"You don't have permission to view this thread's action log.",
diff --git a/surfsense_backend/app/routes/chat_comments_routes.py b/surfsense_backend/app/routes/chat_comments_routes.py
index f5a8fd0af..5bbcd253e 100644
--- a/surfsense_backend/app/routes/chat_comments_routes.py
+++ b/surfsense_backend/app/routes/chat_comments_routes.py
@@ -5,6 +5,7 @@ Routes for chat comments and mentions.
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import User, get_async_session
from app.schemas.chat_comments import (
CommentBatchRequest,
@@ -25,7 +26,7 @@ from app.services.chat_comments_service import (
get_user_mentions,
update_comment,
)
-from app.users import current_active_user
+from app.users import get_auth_context
router = APIRouter()
@@ -34,8 +35,9 @@ router = APIRouter()
async def batch_list_comments(
request: CommentBatchRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Batch-fetch comments for multiple messages in one request."""
return await get_comments_for_messages_batch(session, request.message_ids, user)
@@ -44,8 +46,9 @@ async def batch_list_comments(
async def list_comments(
message_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""List all comments for a message with their replies."""
return await get_comments_for_message(session, message_id, user)
@@ -55,8 +58,9 @@ async def add_comment(
message_id: int,
request: CommentCreateRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Create a top-level comment on an AI response."""
return await create_comment(session, message_id, request.content, user)
@@ -66,8 +70,9 @@ async def add_reply(
comment_id: int,
request: CommentCreateRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Reply to an existing comment."""
return await create_reply(session, comment_id, request.content, user)
@@ -77,8 +82,9 @@ async def edit_comment(
comment_id: int,
request: CommentUpdateRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Update a comment's content (author only)."""
return await update_comment(session, comment_id, request.content, user)
@@ -87,8 +93,9 @@ async def edit_comment(
async def remove_comment(
comment_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Delete a comment (author or user with COMMENTS_DELETE permission)."""
return await delete_comment(session, comment_id, user)
@@ -102,7 +109,8 @@ async def remove_comment(
async def list_mentions(
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""List mentions for the current user."""
return await get_user_mentions(session, user, search_space_id)
diff --git a/surfsense_backend/app/routes/model_connections_routes.py b/surfsense_backend/app/routes/model_connections_routes.py
index 4d32a32af..c97e7b5ac 100644
--- a/surfsense_backend/app/routes/model_connections_routes.py
+++ b/surfsense_backend/app/routes/model_connections_routes.py
@@ -5,6 +5,7 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
+from app.auth.context import AuthContext
from app.config import config
from app.db import (
Connection,
@@ -14,7 +15,6 @@ from app.db import (
NewChatThread,
Permission,
SearchSpace,
- User,
get_async_session,
)
from app.schemas import (
@@ -42,7 +42,7 @@ from app.services.model_connection_service import (
verify_connection,
)
from app.services.provider_registry import REGISTRY
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
router = APIRouter()
@@ -257,8 +257,8 @@ async def _default_unset_roles(
@router.get("/model-providers", response_model=list[ModelProviderRead])
-async def list_model_providers(user: User = Depends(current_active_user)):
- del user
+async def list_model_providers(auth: AuthContext = Depends(get_auth_context)):
+ del auth
local_only = {"ollama_chat", "lm_studio"}
return [
ModelProviderRead(
@@ -298,14 +298,15 @@ async def _load_connection(session: AsyncSession, connection_id: int) -> Connect
async def _assert_connection_access(
session: AsyncSession,
- user: User,
+ auth: AuthContext,
conn: Connection,
permission: str = Permission.LLM_CONFIGS_CREATE.value,
) -> None:
+ user = auth.user
if conn.search_space_id:
await check_permission(
session,
- user,
+ auth,
conn.search_space_id,
permission,
"You don't have permission to manage model connections in this search space",
@@ -318,14 +319,14 @@ async def _assert_connection_access(
@router.get("/global-llm-config-status")
-async def global_llm_config_status(user: User = Depends(current_active_user)):
- del user
+async def global_llm_config_status(auth: AuthContext = Depends(get_auth_context)):
+ del auth
return {"exists": config.GLOBAL_LLM_CONFIG_FILE_EXISTS}
@router.get("/global-model-connections", response_model=list[ConnectionRead])
-async def list_global_connections(user: User = Depends(current_active_user)):
- del user
+async def list_global_connections(auth: AuthContext = Depends(get_auth_context)):
+ del auth
models_by_connection: dict[int, list[dict]] = {}
for model in config.GLOBAL_MODELS:
models_by_connection.setdefault(model["connection_id"], []).append(model)
@@ -339,13 +340,14 @@ async def list_global_connections(user: User = Depends(current_active_user)):
async def list_connections(
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
stmt = select(Connection).options(selectinload(Connection.models))
if search_space_id is not None:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to view model connections in this search space",
@@ -363,8 +365,9 @@ async def list_connections(
async def create_connection(
data: ConnectionCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
if data.scope == ConnectionScope.GLOBAL:
raise HTTPException(status_code=400, detail="GLOBAL connections are YAML-only")
if data.scope == ConnectionScope.SEARCH_SPACE:
@@ -372,7 +375,7 @@ async def create_connection(
raise HTTPException(status_code=400, detail="search_space_id is required")
await check_permission(
session,
- user,
+ auth,
data.search_space_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to create model connections in this search space",
@@ -411,12 +414,13 @@ async def create_connection(
async def preview_connection_models(
data: ConnectionCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
if data.scope == ConnectionScope.SEARCH_SPACE and data.search_space_id is not None:
await check_permission(
session,
- user,
+ auth,
data.search_space_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to create model connections in this search space",
@@ -445,12 +449,13 @@ async def preview_connection_models(
async def test_preview_connection_model(
data: ModelTestPreview,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
if data.scope == ConnectionScope.SEARCH_SPACE and data.search_space_id is not None:
await check_permission(
session,
- user,
+ auth,
data.search_space_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to create model connections in this search space",
@@ -491,11 +496,11 @@ async def update_connection(
connection_id: int,
data: ConnectionUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
conn = await _load_connection(session, connection_id)
await _assert_connection_access(
- session, user, conn, Permission.LLM_CONFIGS_UPDATE.value
+ session, auth, conn, Permission.LLM_CONFIGS_UPDATE.value
)
search_space_id = conn.search_space_id
for key, value in data.model_dump(exclude_unset=True).items():
@@ -512,11 +517,11 @@ async def update_connection(
async def delete_connection(
connection_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
conn = await _load_connection(session, connection_id)
await _assert_connection_access(
- session, user, conn, Permission.LLM_CONFIGS_DELETE.value
+ session, auth, conn, Permission.LLM_CONFIGS_DELETE.value
)
search_space_id = conn.search_space_id
await session.delete(conn)
@@ -533,11 +538,11 @@ async def delete_connection(
async def verify_model_connection(
connection_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
conn = await _load_connection(session, connection_id)
await _assert_connection_access(
- session, user, conn, Permission.LLM_CONFIGS_CREATE.value
+ session, auth, conn, Permission.LLM_CONFIGS_CREATE.value
)
result = await verify_connection(conn)
return VerifyConnectionResponse(
@@ -551,11 +556,11 @@ async def verify_model_connection(
async def discover_connection_models(
connection_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
conn = await _load_connection(session, connection_id)
await _assert_connection_access(
- session, user, conn, Permission.LLM_CONFIGS_CREATE.value
+ session, auth, conn, Permission.LLM_CONFIGS_CREATE.value
)
try:
discovered = await discover_models(conn)
@@ -595,11 +600,11 @@ async def add_manual_model(
connection_id: int,
data: ModelCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
conn = await _load_connection(session, connection_id)
await _assert_connection_access(
- session, user, conn, Permission.LLM_CONFIGS_UPDATE.value
+ session, auth, conn, Permission.LLM_CONFIGS_UPDATE.value
)
model_id = data.model_id.strip()
@@ -640,11 +645,11 @@ async def bulk_update_models(
connection_id: int,
data: ModelsBulkUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
conn = await _load_connection(session, connection_id)
await _assert_connection_access(
- session, user, conn, Permission.LLM_CONFIGS_UPDATE.value
+ session, auth, conn, Permission.LLM_CONFIGS_UPDATE.value
)
search_space_id = conn.search_space_id
@@ -674,7 +679,7 @@ async def update_model(
model_id: int,
data: ModelUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
result = await session.execute(
select(Model)
@@ -685,7 +690,7 @@ async def update_model(
if not model:
raise HTTPException(status_code=404, detail="Model not found")
await _assert_connection_access(
- session, user, model.connection, Permission.LLM_CONFIGS_UPDATE.value
+ session, auth, model.connection, Permission.LLM_CONFIGS_UPDATE.value
)
search_space_id = model.connection.search_space_id
update = data.model_dump(exclude_unset=True)
@@ -704,7 +709,7 @@ async def update_model(
async def test_connection_model(
model_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
result = await session.execute(
select(Model)
@@ -715,7 +720,7 @@ async def test_connection_model(
if not model:
raise HTTPException(status_code=404, detail="Model not found")
await _assert_connection_access(
- session, user, model.connection, Permission.LLM_CONFIGS_UPDATE.value
+ session, auth, model.connection, Permission.LLM_CONFIGS_UPDATE.value
)
result = await test_model(model.connection, model)
await session.commit()
@@ -730,11 +735,11 @@ async def test_connection_model(
async def get_model_roles(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to view model roles in this search space",
@@ -756,11 +761,11 @@ async def update_model_roles(
search_space_id: int,
data: ModelRolesUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.LLM_CONFIGS_UPDATE.value,
"You don't have permission to update model roles in this search space",
diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index b5bc2571e..d76211dfc 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -24,6 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
+from app.auth.context import AuthContext
from app.agents.chat.multi_agent_chat.main_agent.middleware.busy_mutex import (
get_cancel_state,
is_cancel_requested,
@@ -75,7 +76,7 @@ from app.tasks.chat.streaming.flows import (
stream_new_chat,
stream_resume_chat,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.perf import get_perf_logger
from app.utils.rbac import check_permission
from app.utils.user_message_multimodal import (
@@ -595,8 +596,9 @@ async def list_threads(
search_space_id: int,
limit: int | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all accessible threads for the current user in a search space.
Returns threads and archived_threads for ThreadListPrimitive.
@@ -615,7 +617,7 @@ async def list_threads(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
@@ -702,8 +704,9 @@ async def search_threads(
search_space_id: int,
title: str,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Search accessible threads by title in a search space.
@@ -721,7 +724,7 @@ async def search_threads(
try:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
@@ -794,8 +797,9 @@ async def search_threads(
async def create_thread(
thread: NewChatThreadCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create a new chat thread.
@@ -807,7 +811,7 @@ async def create_thread(
try:
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_CREATE.value,
"You don't have permission to create chats in this search space",
@@ -852,8 +856,9 @@ async def create_thread(
async def get_thread_messages(
thread_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get a thread with all its messages.
This is used by ThreadHistoryAdapter.load() to restore conversation.
@@ -877,7 +882,7 @@ async def get_thread_messages(
# Check permission to read chats in this search space
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
@@ -936,8 +941,9 @@ async def get_thread_messages(
async def get_thread_full(
thread_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get full thread details with all messages.
@@ -964,7 +970,7 @@ async def get_thread_full(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
@@ -1005,8 +1011,9 @@ async def update_thread(
thread_id: int,
thread_update: NewChatThreadUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update a thread (title, archived status).
Used for renaming and archiving threads.
@@ -1027,7 +1034,7 @@ async def update_thread(
await check_permission(
session,
- user,
+ auth,
db_thread.search_space_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
@@ -1074,8 +1081,9 @@ async def update_thread(
async def delete_thread(
thread_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a thread and all its messages.
@@ -1095,7 +1103,7 @@ async def delete_thread(
await check_permission(
session,
- user,
+ auth,
db_thread.search_space_id,
Permission.CHATS_DELETE.value,
"You don't have permission to delete chats in this search space",
@@ -1146,8 +1154,9 @@ async def update_thread_visibility(
thread_id: int,
visibility_update: NewChatThreadVisibilityUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update the visibility/sharing settings of a thread.
@@ -1168,7 +1177,7 @@ async def update_thread_visibility(
await check_permission(
session,
- user,
+ auth,
db_thread.search_space_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
@@ -1217,8 +1226,9 @@ async def update_thread_visibility(
async def create_thread_snapshot(
thread_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create a public snapshot of the thread.
@@ -1239,8 +1249,9 @@ async def create_thread_snapshot(
async def list_thread_snapshots(
thread_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List all public snapshots for this thread.
@@ -1262,8 +1273,9 @@ async def delete_thread_snapshot(
thread_id: int,
snapshot_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a specific snapshot.
@@ -1290,8 +1302,9 @@ async def append_message(
thread_id: int,
request: Request,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
.. deprecated:: 2026-05
Replaced by the **SSE-based message ID handshake**. The streaming
@@ -1370,7 +1383,7 @@ async def append_message(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
@@ -1597,8 +1610,9 @@ async def list_messages(
skip: int = 0,
limit: int = 100,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
List messages in a thread with pagination.
@@ -1620,7 +1634,7 @@ async def list_messages(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_READ.value,
"You don't have permission to read chats in this search space",
@@ -1662,7 +1676,7 @@ async def list_messages(
@router.get("/agent/tools", response_model=list[AgentToolInfo])
async def list_agent_tools(
- _user: User = Depends(current_active_user),
+ _auth: AuthContext = Depends(get_auth_context),
):
"""Return the list of built-in agent tools with their metadata.
@@ -1691,8 +1705,9 @@ async def handle_new_chat(
request: NewChatRequest,
http_request: Request,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Stream chat responses from the deep agent.
@@ -1717,7 +1732,7 @@ async def handle_new_chat(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_CREATE.value,
"You don't have permission to chat in this search space",
@@ -1821,8 +1836,9 @@ async def cancel_active_turn(
thread_id: int,
response: Response,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Signal cancellation for the currently running turn on ``thread_id``."""
result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == thread_id)
@@ -1833,7 +1849,7 @@ async def cancel_active_turn(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
@@ -1873,8 +1889,9 @@ async def cancel_active_turn(
async def get_turn_status(
thread_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == thread_id)
)
@@ -1884,7 +1901,7 @@ async def get_turn_status(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_READ.value,
"You don't have permission to view chats in this search space",
@@ -1911,8 +1928,9 @@ async def regenerate_response(
request: RegenerateRequest,
http_request: Request,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Regenerate the AI response for a chat thread.
@@ -1947,7 +1965,7 @@ async def regenerate_response(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_UPDATE.value,
"You don't have permission to update chats in this search space",
@@ -2356,8 +2374,9 @@ async def resume_chat(
request: ResumeRequest,
http_request: Request,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
try:
result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == thread_id)
@@ -2369,7 +2388,7 @@ async def resume_chat(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_CREATE.value,
"You don't have permission to chat in this search space",
diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py
index 516e976e6..4029cd139 100644
--- a/surfsense_backend/app/routes/public_chat_routes.py
+++ b/surfsense_backend/app/routes/public_chat_routes.py
@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import User, get_async_session
from app.schemas.new_chat import (
CloneResponse,
@@ -23,7 +24,7 @@ from app.services.public_chat_service import (
get_snapshot_report,
get_snapshot_video_presentation,
)
-from app.users import current_active_user
+from app.users import get_auth_context
router = APIRouter(prefix="/public", tags=["public"])
@@ -46,8 +47,9 @@ async def read_public_chat(
async def clone_public_chat(
share_token: str,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Clone a public chat snapshot to the user's account.
diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py
index 905482010..b44f6f37c 100644
--- a/surfsense_backend/app/services/chat_comments_service.py
+++ b/surfsense_backend/app/services/chat_comments_service.py
@@ -9,6 +9,7 @@ from sqlalchemy import delete, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
+from app.auth.context import AuthContext
from app.db import (
ChatComment,
ChatCommentMention,
@@ -138,8 +139,9 @@ async def get_comment_thread_participants(
async def get_comments_for_message(
session: AsyncSession,
message_id: int,
- user: User,
+ auth: AuthContext,
) -> CommentListResponse:
+ user = auth.user
"""
Get all comments for a message with their replies.
@@ -169,7 +171,7 @@ async def get_comments_for_message(
# Check permission to read comments
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.COMMENTS_READ.value,
"You don't have permission to read comments in this search space",
@@ -268,8 +270,9 @@ async def get_comments_for_message(
async def get_comments_for_messages_batch(
session: AsyncSession,
message_ids: list[int],
- user: User,
+ auth: AuthContext,
) -> CommentBatchResponse:
+ user = auth.user
"""
Batch-fetch comments for multiple messages in a single DB round-trip.
@@ -295,7 +298,7 @@ async def get_comments_for_messages_batch(
for ss_id in search_space_ids:
await check_permission(
session,
- user,
+ auth,
ss_id,
Permission.COMMENTS_READ.value,
"You don't have permission to read comments in this search space",
@@ -409,8 +412,9 @@ async def create_comment(
session: AsyncSession,
message_id: int,
content: str,
- user: User,
+ auth: AuthContext,
) -> CommentResponse:
+ user = auth.user
"""
Create a top-level comment on an AI response.
@@ -521,8 +525,9 @@ async def create_reply(
session: AsyncSession,
comment_id: int,
content: str,
- user: User,
+ auth: AuthContext,
) -> CommentReplyResponse:
+ user = auth.user
"""
Create a reply to an existing comment.
@@ -657,8 +662,9 @@ async def update_comment(
session: AsyncSession,
comment_id: int,
content: str,
- user: User,
+ auth: AuthContext,
) -> CommentReplyResponse:
+ user = auth.user
"""
Update a comment's content (author only).
@@ -797,8 +803,9 @@ async def update_comment(
async def delete_comment(
session: AsyncSession,
comment_id: int,
- user: User,
+ auth: AuthContext,
) -> dict:
+ user = auth.user
"""
Delete a comment (author or user with COMMENTS_DELETE permission).
@@ -844,9 +851,10 @@ async def delete_comment(
async def get_user_mentions(
session: AsyncSession,
- user: User,
+ auth: AuthContext,
search_space_id: int | None = None,
) -> MentionListResponse:
+ user = auth.user
"""
Get mentions for the current user, optionally filtered by search space.
diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py
index d17f411b8..0df69de09 100644
--- a/surfsense_backend/app/services/public_chat_service.py
+++ b/surfsense_backend/app/services/public_chat_service.py
@@ -21,6 +21,7 @@ from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
+from app.auth.context import AuthContext
from app.db import (
ChatVisibility,
NewChatMessage,
@@ -163,8 +164,9 @@ def compute_content_hash(messages: list[dict]) -> str:
async def create_snapshot(
session: AsyncSession,
thread_id: int,
- user: User,
+ auth: AuthContext,
) -> dict:
+ user = auth.user
"""
Create a public snapshot of a chat thread.
@@ -186,7 +188,7 @@ async def create_snapshot(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.PUBLIC_SHARING_CREATE.value,
"You don't have permission to create public share links",
@@ -431,8 +433,9 @@ async def get_public_chat(
async def list_snapshots_for_thread(
session: AsyncSession,
thread_id: int,
- user: User,
+ auth: AuthContext,
) -> list[dict]:
+ user = auth.user
"""List all public snapshots for a thread."""
from app.config import config
@@ -447,7 +450,7 @@ async def list_snapshots_for_thread(
# Check permission to view public share links
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.PUBLIC_SHARING_VIEW.value,
"You don't have permission to view public share links",
@@ -477,14 +480,15 @@ async def list_snapshots_for_thread(
async def list_snapshots_for_search_space(
session: AsyncSession,
search_space_id: int,
- user: User,
+ auth: AuthContext,
) -> list[dict]:
+ user = auth.user
"""List all public snapshots for a search space."""
from app.config import config
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.PUBLIC_SHARING_VIEW.value,
"You don't have permission to view public share links",
@@ -534,8 +538,9 @@ async def delete_snapshot(
session: AsyncSession,
thread_id: int,
snapshot_id: int,
- user: User,
+ auth: AuthContext,
) -> bool:
+ user = auth.user
"""Delete a specific snapshot. Only thread owner can delete."""
# Get snapshot with thread
result = await session.execute(
@@ -553,7 +558,7 @@ async def delete_snapshot(
await check_permission(
session,
- user,
+ auth,
snapshot.thread.search_space_id,
Permission.PUBLIC_SHARING_DELETE.value,
"You don't have permission to delete public share links",
From 7ec6fa4d1f77ce6e9af42ad2f9896bb0902a3b8a Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:28:12 +0530
Subject: [PATCH 10/29] feat: enforce API access for integration routes
---
surfsense_backend/app/podcasts/api/routes.py | 44 +++++++++++------
surfsense_backend/app/routes/export_routes.py | 8 +--
.../app/routes/gateway_webhook_routes.py | 49 ++++++++++++-------
.../routes/gateway_whatsapp_baileys_routes.py | 11 +++--
.../app/routes/image_generation_routes.py | 23 +++++----
surfsense_backend/app/routes/logs_routes.py | 33 ++++++++-----
.../app/routes/sandbox_routes.py | 8 +--
.../app/routes/video_presentations_routes.py | 23 +++++----
8 files changed, 125 insertions(+), 74 deletions(-)
diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py
index cfcb2ede9..2f4c8e4d9 100644
--- a/surfsense_backend/app/podcasts/api/routes.py
+++ b/surfsense_backend/app/podcasts/api/routes.py
@@ -18,6 +18,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.config import config as app_config
from app.db import (
Permission,
@@ -42,7 +43,7 @@ from app.podcasts.voices import (
provider_from_service,
render_voice_preview,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
from .schemas import (
@@ -63,8 +64,9 @@ async def list_podcasts(
skip: int = 0,
limit: int = 100,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
if skip < 0 or limit < 1:
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
@@ -132,8 +134,9 @@ async def list_languages():
@router.get("/podcasts/voices/{voice_id}/preview")
async def preview_voice(
voice_id: str,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""A short audio sample of a voice, so users pick by sound."""
if not app_config.TTS_SERVICE:
raise HTTPException(status_code=503, detail="No TTS provider configured")
@@ -156,8 +159,9 @@ async def preview_voice(
async def create_podcast(
body: CreatePodcastRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
await _require(session, user, body.search_space_id, Permission.PODCASTS_CREATE)
service = PodcastService(session)
@@ -185,8 +189,9 @@ async def create_podcast(
async def get_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_READ)
return PodcastDetail.of(podcast)
@@ -196,8 +201,9 @@ async def update_spec(
podcast_id: int,
body: UpdateSpecRequest,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).update_spec(
@@ -211,8 +217,9 @@ async def update_spec(
async def approve_brief(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Approve the brief and start drafting the transcript."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
@@ -228,8 +235,9 @@ async def approve_brief(
async def regenerate_transcript(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Reopen the brief gate for a fresh take; drafting waits for re-approval."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
@@ -242,8 +250,9 @@ async def regenerate_transcript(
async def revert_regeneration(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Back out of a regeneration and return to the finished episode."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
@@ -256,8 +265,9 @@ async def revert_regeneration(
async def cancel_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).cancel(podcast)
@@ -269,8 +279,9 @@ async def cancel_podcast(
async def delete_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_DELETE)
await purge_audio(podcast)
await session.delete(podcast)
@@ -282,8 +293,9 @@ async def delete_podcast(
async def stream_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_READ)
if podcast.storage_key:
@@ -323,13 +335,14 @@ async def stream_podcast(
async def _require(
session: AsyncSession,
- user: User,
+ auth: AuthContext,
search_space_id: int,
permission: Permission,
) -> None:
+ user = auth.user
await check_permission(
session,
- user,
+ auth,
search_space_id,
permission.value,
"You don't have permission for podcasts in this search space",
@@ -338,10 +351,11 @@ async def _require(
async def _load(
session: AsyncSession,
- user: User,
+ auth: AuthContext,
podcast_id: int,
permission: Permission,
) -> Podcast:
+ user = auth.user
podcast = await PodcastRepository(session).get(podcast_id)
if podcast is None:
raise HTTPException(status_code=404, detail="Podcast not found")
diff --git a/surfsense_backend/app/routes/export_routes.py b/surfsense_backend/app/routes/export_routes.py
index 4f2b545a3..8e419157f 100644
--- a/surfsense_backend/app/routes/export_routes.py
+++ b/surfsense_backend/app/routes/export_routes.py
@@ -7,9 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import Permission, User, get_async_session
from app.services.export_service import build_export_zip
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
logger = logging.getLogger(__name__)
@@ -24,12 +25,13 @@ async def export_knowledge_base(
None, description="Export only this folder's subtree"
),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Export documents as a ZIP of markdown files preserving folder structure."""
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to export documents in this search space",
diff --git a/surfsense_backend/app/routes/gateway_webhook_routes.py b/surfsense_backend/app/routes/gateway_webhook_routes.py
index 9b4af4b83..0d05f4baf 100644
--- a/surfsense_backend/app/routes/gateway_webhook_routes.py
+++ b/surfsense_backend/app/routes/gateway_webhook_routes.py
@@ -20,6 +20,7 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import JSONResponse, RedirectResponse, Response
+from app.auth.context import AuthContext
from app.config import config
from app.db import (
ExternalChatAccount,
@@ -51,7 +52,7 @@ from app.observability.metrics import (
record_gateway_inbox_write,
record_gateway_webhook_parse_error,
)
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
from app.utils.rbac import check_search_space_access
@@ -250,14 +251,15 @@ def _telegram_message(payload: dict[str, Any]) -> dict[str, Any] | None:
@router.get("/slack/install")
async def install_slack_gateway(
search_space_id: int,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, str]:
+ user = auth.user
if not _slack_gateway_enabled():
raise HTTPException(
status_code=500, detail="Slack gateway OAuth is not configured"
)
- await check_search_space_access(session, user, search_space_id)
+ await check_search_space_access(session, auth, search_space_id)
state = _get_state_manager().generate_secure_state(search_space_id, user.id)
auth_params = {
"client_id": config.GATEWAY_SLACK_CLIENT_ID,
@@ -409,14 +411,15 @@ async def slack_gateway_callback(
@router.get("/discord/install")
async def install_discord_gateway(
search_space_id: int,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, str]:
+ user = auth.user
if not _discord_gateway_enabled():
raise HTTPException(
status_code=500, detail="Discord gateway OAuth is not configured"
)
- await check_search_space_access(session, user, search_space_id)
+ await check_search_space_access(session, auth, search_space_id)
state = _get_state_manager().generate_secure_state(search_space_id, user.id)
auth_params = {
"client_id": config.DISCORD_CLIENT_ID,
@@ -712,10 +715,11 @@ async def telegram_webhook(
@router.post("/bindings/start", response_model=StartBindingResponse)
async def start_binding(
body: StartBindingRequest,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> StartBindingResponse:
- await check_search_space_access(session, user, body.search_space_id)
+ user = auth.user
+ await check_search_space_access(session, auth, body.search_space_id)
code = generate_pairing_code()
if body.platform == ExternalChatPlatform.TELEGRAM:
if not _telegram_gateway_enabled():
@@ -774,9 +778,10 @@ async def start_binding(
@router.get("/bindings")
async def list_bindings(
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> list[dict[str, Any]]:
+ user = auth.user
result = await session.execute(
select(ExternalChatBinding, ExternalChatAccount)
.join(
@@ -803,9 +808,10 @@ async def list_bindings(
@router.get("/connections")
async def list_connections(
platform: ExternalChatPlatform | None = None,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> list[dict[str, Any]]:
+ user = auth.user
active_whatsapp_mode = _active_whatsapp_account_mode()
if platform == ExternalChatPlatform.WHATSAPP and active_whatsapp_mode is None:
return []
@@ -946,9 +952,10 @@ async def list_connections(
@router.get("/platforms")
async def list_platforms(
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> list[dict[str, Any]]:
+ user = auth.user
result = await session.execute(
select(ExternalChatAccount).where(
(ExternalChatAccount.owner_user_id == user.id)
@@ -970,8 +977,9 @@ async def list_platforms(
@config_router.get("/config")
async def get_gateway_config(
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> dict[str, bool | str]:
+ user = auth.user
if not config.GATEWAY_ENABLED:
return {
"enabled": False,
@@ -993,9 +1001,10 @@ async def get_gateway_config(
async def update_binding_search_space(
binding_id: int,
body: UpdateBindingSearchSpaceRequest,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
+ user = auth.user
binding = await session.get(ExternalChatBinding, binding_id)
if binding is None or binding.user_id != user.id:
raise HTTPException(status_code=404, detail="Binding not found")
@@ -1010,7 +1019,7 @@ 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, user, body.search_space_id)
+ 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
binding.new_chat_thread_id = None
@@ -1023,9 +1032,10 @@ async def update_binding_search_space(
async def update_gateway_account_search_space(
account_id: int,
body: UpdateAccountSearchSpaceRequest,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
+ user = auth.user
account = await session.get(ExternalChatAccount, account_id)
if (
account is None
@@ -1036,7 +1046,7 @@ async def update_gateway_account_search_space(
):
raise HTTPException(status_code=404, detail="Gateway account not found")
- await check_search_space_access(session, user, body.search_space_id)
+ await check_search_space_access(session, auth, body.search_space_id)
account.owner_search_space_id = body.search_space_id
account.updated_at = datetime.now(UTC)
@@ -1061,9 +1071,10 @@ async def update_gateway_account_search_space(
@router.delete("/bindings/{binding_id}")
async def delete_binding(
binding_id: int,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
+ user = auth.user
binding = await session.get(ExternalChatBinding, binding_id)
if binding is None or binding.user_id != user.id:
raise HTTPException(status_code=404, detail="Binding not found")
@@ -1078,9 +1089,10 @@ async def delete_binding(
@router.delete("/accounts/{account_id}")
async def delete_gateway_account(
account_id: int,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
+ user = auth.user
account = await session.get(ExternalChatAccount, account_id)
if (
account is None
@@ -1114,9 +1126,10 @@ async def delete_gateway_account(
@router.post("/bindings/{binding_id}/resume")
async def resume_external_chat_binding(
binding_id: int,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
+ user = auth.user
binding = await session.get(ExternalChatBinding, binding_id)
if binding is None or binding.user_id != user.id:
raise HTTPException(status_code=404, detail="Binding not found")
diff --git a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
index 1fcf5c438..370b1cc8d 100644
--- a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
+++ b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
@@ -10,6 +10,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.config import config
from app.db import (
ExternalChatAccount,
@@ -20,7 +21,7 @@ from app.db import (
get_async_session,
)
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_search_space_access
router = APIRouter(prefix="/gateway/whatsapp/baileys", tags=["gateway"])
@@ -60,11 +61,12 @@ async def _get_user_whatsapp_account(
@router.post("/pair")
async def request_pairing_code(
body: BaileysPairRequest,
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, Any]:
+ user = auth.user
_ensure_baileys_enabled()
- await check_search_space_access(session, user, body.search_space_id)
+ await check_search_space_access(session, auth, body.search_space_id)
adapter = WhatsAppBaileysAdapter()
try:
pairing = await adapter.request_pairing_code(phone_number=body.phone_number)
@@ -97,8 +99,9 @@ async def request_pairing_code(
@router.get("/health")
async def bridge_health(
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> dict[str, Any]:
+ user = auth.user
_ensure_baileys_enabled()
adapter = WhatsAppBaileysAdapter()
try:
diff --git a/surfsense_backend/app/routes/image_generation_routes.py b/surfsense_backend/app/routes/image_generation_routes.py
index cc3e51ed5..0d9841c4c 100644
--- a/surfsense_backend/app/routes/image_generation_routes.py
+++ b/surfsense_backend/app/routes/image_generation_routes.py
@@ -16,6 +16,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
+from app.auth.context import AuthContext
from app.config import config
from app.db import (
ImageGeneration,
@@ -46,7 +47,7 @@ from app.services.image_gen_router_service import (
)
from app.services.model_capabilities import has_capability
from app.services.model_resolver import to_litellm
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
from app.utils.signed_image_urls import verify_image_token
@@ -231,8 +232,9 @@ async def _execute_image_generation(
async def create_image_generation(
data: ImageGenerationCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Create and execute an image generation request.
Premium configs are gated by the user's shared premium credit pool.
@@ -256,7 +258,7 @@ async def create_image_generation(
try:
await check_permission(
session,
- user,
+ auth,
data.search_space_id,
Permission.IMAGE_GENERATIONS_CREATE.value,
"You don't have permission to create image generations in this search space",
@@ -351,8 +353,9 @@ async def list_image_generations(
skip: int = 0,
limit: int = 50,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""List image generations."""
if skip < 0 or limit < 1:
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
@@ -363,7 +366,7 @@ async def list_image_generations(
if search_space_id is not None:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.IMAGE_GENERATIONS_READ.value,
"You don't have permission to read image generations in this search space",
@@ -403,8 +406,9 @@ async def list_image_generations(
async def get_image_generation(
image_gen_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Get a specific image generation by ID."""
try:
result = await session.execute(
@@ -416,7 +420,7 @@ async def get_image_generation(
await check_permission(
session,
- user,
+ auth,
image_gen.search_space_id,
Permission.IMAGE_GENERATIONS_READ.value,
"You don't have permission to read image generations in this search space",
@@ -435,8 +439,9 @@ async def get_image_generation(
async def delete_image_generation(
image_gen_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Delete an image generation record."""
try:
result = await session.execute(
@@ -448,7 +453,7 @@ async def delete_image_generation(
await check_permission(
session,
- user,
+ auth,
db_image_gen.search_space_id,
Permission.IMAGE_GENERATIONS_DELETE.value,
"You don't have permission to delete image generations in this search space",
diff --git a/surfsense_backend/app/routes/logs_routes.py b/surfsense_backend/app/routes/logs_routes.py
index b82e02077..16400ef0b 100644
--- a/surfsense_backend/app/routes/logs_routes.py
+++ b/surfsense_backend/app/routes/logs_routes.py
@@ -5,6 +5,7 @@ from sqlalchemy import and_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
+from app.auth.context import AuthContext
from app.db import (
Log,
LogLevel,
@@ -16,7 +17,7 @@ from app.db import (
get_async_session,
)
from app.schemas import LogCreate, LogRead, LogUpdate
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
router = APIRouter()
@@ -26,8 +27,9 @@ router = APIRouter()
async def create_log(
log: LogCreate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Create a new log entry.
Note: This is typically called internally. Requires LOGS_READ permission (since logs are usually system-generated).
@@ -36,7 +38,7 @@ async def create_log(
# Check if the user has access to the search space
await check_permission(
session,
- user,
+ auth,
log.search_space_id,
Permission.LOGS_READ.value,
"You don't have permission to access logs in this search space",
@@ -67,8 +69,9 @@ async def read_logs(
start_date: datetime | None = None,
end_date: datetime | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get logs with optional filtering.
Requires LOGS_READ permission for the search space(s).
@@ -81,7 +84,7 @@ async def read_logs(
# Check permission for specific search space
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.LOGS_READ.value,
"You don't have permission to read logs in this search space",
@@ -136,8 +139,9 @@ async def read_logs(
async def read_log(
log_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get a specific log by ID.
Requires LOGS_READ permission for the search space.
@@ -152,7 +156,7 @@ async def read_log(
# Check permission for the search space
await check_permission(
session,
- user,
+ auth,
log.search_space_id,
Permission.LOGS_READ.value,
"You don't have permission to read logs in this search space",
@@ -172,8 +176,9 @@ async def update_log(
log_id: int,
log_update: LogUpdate,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Update a log entry.
Requires LOGS_READ permission (logs are typically updated by system).
@@ -188,7 +193,7 @@ async def update_log(
# Check permission for the search space
await check_permission(
session,
- user,
+ auth,
db_log.search_space_id,
Permission.LOGS_READ.value,
"You don't have permission to access logs in this search space",
@@ -215,8 +220,9 @@ async def update_log(
async def delete_log(
log_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a log entry.
Requires LOGS_DELETE permission for the search space.
@@ -231,7 +237,7 @@ async def delete_log(
# Check permission for the search space
await check_permission(
session,
- user,
+ auth,
db_log.search_space_id,
Permission.LOGS_DELETE.value,
"You don't have permission to delete logs in this search space",
@@ -254,8 +260,9 @@ async def get_logs_summary(
search_space_id: int,
hours: int = 24,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get a summary of logs for a search space in the last X hours.
Requires LOGS_READ permission for the search space.
@@ -264,7 +271,7 @@ async def get_logs_summary(
# Check permission
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.LOGS_READ.value,
"You don't have permission to read logs in this search space",
diff --git a/surfsense_backend/app/routes/sandbox_routes.py b/surfsense_backend/app/routes/sandbox_routes.py
index fefe51997..e7974b993 100644
--- a/surfsense_backend/app/routes/sandbox_routes.py
+++ b/surfsense_backend/app/routes/sandbox_routes.py
@@ -10,8 +10,9 @@ from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
+from app.auth.context import AuthContext
from app.db import NewChatThread, Permission, User, get_async_session
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
logger = logging.getLogger(__name__)
@@ -47,8 +48,9 @@ async def download_sandbox_file(
thread_id: int,
path: str = Query(..., description="Absolute path of the file inside the sandbox"),
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""Download a file from the Daytona sandbox associated with a chat thread."""
from app.agents.chat.multi_agent_chat.shared.middleware.filesystem.sandbox import (
@@ -68,7 +70,7 @@ async def download_sandbox_file(
await check_permission(
session,
- user,
+ auth,
thread.search_space_id,
Permission.CHATS_READ.value,
"You don't have permission to access files in this thread",
diff --git a/surfsense_backend/app/routes/video_presentations_routes.py b/surfsense_backend/app/routes/video_presentations_routes.py
index ed694b9bf..189a050e4 100644
--- a/surfsense_backend/app/routes/video_presentations_routes.py
+++ b/surfsense_backend/app/routes/video_presentations_routes.py
@@ -16,6 +16,7 @@ from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth.context import AuthContext
from app.db import (
Permission,
SearchSpace,
@@ -25,7 +26,7 @@ from app.db import (
get_async_session,
)
from app.schemas import VideoPresentationRead
-from app.users import current_active_user
+from app.users import get_auth_context
from app.utils.rbac import check_permission
router = APIRouter()
@@ -37,8 +38,9 @@ async def read_video_presentations(
limit: int = 100,
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ 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).
@@ -49,7 +51,7 @@ async def read_video_presentations(
if search_space_id is not None:
await check_permission(
session,
- user,
+ auth,
search_space_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
"You don't have permission to read video presentations in this search space",
@@ -89,8 +91,9 @@ async def read_video_presentations(
async def read_video_presentation(
video_presentation_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Get a specific video presentation by ID.
Requires authentication with VIDEO_PRESENTATIONS_READ permission.
@@ -112,7 +115,7 @@ async def read_video_presentation(
await check_permission(
session,
- user,
+ auth,
video_pres.search_space_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
"You don't have permission to read video presentations in this search space",
@@ -132,8 +135,9 @@ async def read_video_presentation(
async def delete_video_presentation(
video_presentation_id: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Delete a video presentation.
Requires VIDEO_PRESENTATIONS_DELETE permission for the search space.
@@ -151,7 +155,7 @@ async def delete_video_presentation(
await check_permission(
session,
- user,
+ auth,
db_video_pres.search_space_id,
Permission.VIDEO_PRESENTATIONS_DELETE.value,
"You don't have permission to delete video presentations in this search space",
@@ -175,8 +179,9 @@ async def stream_slide_audio(
video_presentation_id: int,
slide_number: int,
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
):
+ user = auth.user
"""
Stream the audio file for a specific slide in a video presentation.
The slide_number is 1-based. Audio path is read from the slides JSONB.
@@ -194,7 +199,7 @@ async def stream_slide_audio(
await check_permission(
session,
- user,
+ auth,
video_pres.search_space_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
"You don't have permission to access video presentations in this search space",
From 096dea45d4eb7500bf1190801fc054fc8f75638a Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:28:35 +0530
Subject: [PATCH 11/29] refactor: pass auth context through automations
---
.../main_agent/runtime/factory.py | 3 +++
.../main_agent/tools/automation/create.py | 5 ++++-
.../main_agent/tools/registry.py | 1 +
.../actions/builtin/agent_task/invoke.py | 10 +++++++++-
.../app/automations/services/automation.py | 16 +++++++++-------
.../app/automations/services/run.py | 15 ++++++++-------
.../app/automations/services/trigger.py | 15 ++++++++-------
7 files changed, 42 insertions(+), 23 deletions(-)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/factory.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/factory.py
index 10a734192..d823a5a06 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/factory.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/factory.py
@@ -34,6 +34,7 @@ from app.agents.chat.runtime.llm_config import AgentConfig
from app.agents.chat.runtime.prompt_caching import (
apply_litellm_prompt_caching,
)
+from app.auth.context import AuthContext
from app.db import ChatVisibility
from app.services.connector_service import ConnectorService
from app.services.user_tool_allowlist import (
@@ -73,6 +74,7 @@ async def create_multi_agent_chat_deep_agent(
anon_session_id: str | None = None,
filesystem_selection: FilesystemSelection | None = None,
image_gen_model_id: int | None = None,
+ auth_context: AuthContext | None = None,
):
"""Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled.
@@ -139,6 +141,7 @@ async def create_multi_agent_chat_deep_agent(
"connector_service": connector_service,
"firecrawl_api_key": firecrawl_api_key,
"user_id": user_id,
+ "auth_context": auth_context,
"thread_id": thread_id,
"thread_visibility": visibility,
"available_connectors": available_connectors,
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py
index 4472a11ac..c14413cf4 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py
@@ -30,6 +30,7 @@ from pydantic import ValidationError
from app.agents.chat.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
request_approval,
)
+from app.auth.context import AuthContext
from app.automations.schemas.api import AutomationCreate
from app.automations.services.automation import AutomationService
from app.db import User, async_session_maker
@@ -47,6 +48,7 @@ def create_create_automation_tool(
search_space_id: int,
user_id: str | UUID,
llm: Any,
+ auth_context: AuthContext | None = None,
):
"""Factory for the ``create_automation`` tool.
@@ -172,7 +174,8 @@ def create_create_automation_tool(
"status": "error",
"message": "user not found in this session",
}
- service = AutomationService(session=session, user=user)
+ auth = auth_context or AuthContext.system(user, source="agent")
+ service = AutomationService(session=session, auth=auth)
created = await service.create(final_validated)
return {
"status": "saved",
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/registry.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/registry.py
index f04d7cdec..5e7c2d5d6 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/registry.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/registry.py
@@ -60,6 +60,7 @@ def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool:
return create_create_automation_tool(
search_space_id=deps["search_space_id"],
user_id=deps["user_id"],
+ auth_context=deps.get("auth_context"),
llm=deps["llm"],
)
diff --git a/surfsense_backend/app/automations/actions/builtin/agent_task/invoke.py b/surfsense_backend/app/automations/actions/builtin/agent_task/invoke.py
index c3a35930d..b2f441961 100644
--- a/surfsense_backend/app/automations/actions/builtin/agent_task/invoke.py
+++ b/surfsense_backend/app/automations/actions/builtin/agent_task/invoke.py
@@ -16,7 +16,8 @@ from app.agents.chat.runtime.mention_resolver import (
substitute_in_text,
)
from app.agents.chat.shared.context import SurfSenseContextSchema
-from app.db import ChatVisibility, async_session_maker
+from app.auth.context import AuthContext
+from app.db import ChatVisibility, User, async_session_maker
from app.schemas.new_chat import MentionedDocumentInfo
from ...types import ActionContext
@@ -147,6 +148,12 @@ async def run_agent_task(
decision = "approve" if auto_approve_all else "reject"
async with async_session_maker() as agent_session:
+ auth_context = None
+ if ctx.creator_user_id:
+ user = await agent_session.get(User, ctx.creator_user_id)
+ if user is not None:
+ auth_context = AuthContext.system(user, source="automation")
+
deps = await build_dependencies(
session=agent_session,
search_space_id=ctx.search_space_id,
@@ -168,6 +175,7 @@ async def run_agent_task(
thread_visibility=ChatVisibility.PRIVATE,
mentioned_document_ids=mentioned_document_ids,
image_gen_model_id=ctx.image_gen_model_id,
+ auth_context=auth_context,
)
agent_query, runtime_context = await _resolve_mention_context(
diff --git a/surfsense_backend/app/automations/services/automation.py b/surfsense_backend/app/automations/services/automation.py
index 1d371c35d..261d41bfc 100644
--- a/surfsense_backend/app/automations/services/automation.py
+++ b/surfsense_backend/app/automations/services/automation.py
@@ -27,17 +27,19 @@ from app.automations.services.model_policy import (
)
from app.automations.triggers import get_trigger
from app.automations.triggers.builtin.schedule import compute_next_fire_at
-from app.db import Permission, SearchSpace, User, get_async_session
-from app.users import current_active_user
+from app.auth.context import AuthContext
+from app.db import Permission, SearchSpace, get_async_session
+from app.users import get_auth_context
from app.utils.rbac import check_permission
class AutomationService:
"""Lifecycle of the ``Automation`` resource."""
- def __init__(self, *, session: AsyncSession, user: User) -> None:
+ def __init__(self, *, session: AsyncSession, auth: AuthContext) -> None:
self.session = session
- self.user = user
+ self.auth = auth
+ self.user = auth.user
async def create(self, payload: AutomationCreate) -> Automation:
"""Create an automation and its initial triggers in one transaction."""
@@ -235,7 +237,7 @@ class AutomationService:
async def _authorize(self, search_space_id: int, permission: str) -> None:
await check_permission(
self.session,
- self.user,
+ self.auth,
search_space_id,
permission,
f"You don't have permission to {permission.split(':')[1]} automations in this search space",
@@ -274,6 +276,6 @@ def _build_trigger(spec: TriggerCreate) -> AutomationTrigger:
def get_automation_service(
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> AutomationService:
- return AutomationService(session=session, user=user)
+ return AutomationService(session=session, auth=auth)
diff --git a/surfsense_backend/app/automations/services/run.py b/surfsense_backend/app/automations/services/run.py
index 3ef80416f..8ef763e5e 100644
--- a/surfsense_backend/app/automations/services/run.py
+++ b/surfsense_backend/app/automations/services/run.py
@@ -8,17 +8,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.run import AutomationRun
-from app.db import Permission, User, get_async_session
-from app.users import current_active_user
+from app.auth.context import AuthContext
+from app.db import Permission, get_async_session
+from app.users import get_auth_context
from app.utils.rbac import check_permission
class RunService:
"""Read-only access to ``AutomationRun`` history."""
- def __init__(self, *, session: AsyncSession, user: User) -> None:
+ def __init__(self, *, session: AsyncSession, auth: AuthContext) -> None:
self.session = session
- self.user = user
+ self.auth = auth
async def list(
self,
@@ -63,7 +64,7 @@ class RunService:
)
await check_permission(
self.session,
- self.user,
+ self.auth,
automation.search_space_id,
permission,
f"You don't have permission to {permission.split(':')[1]} automations in this search space",
@@ -73,6 +74,6 @@ class RunService:
def get_run_service(
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> RunService:
- return RunService(session=session, user=user)
+ return RunService(session=session, auth=auth)
diff --git a/surfsense_backend/app/automations/services/trigger.py b/surfsense_backend/app/automations/services/trigger.py
index 523153927..7ff6e56fa 100644
--- a/surfsense_backend/app/automations/services/trigger.py
+++ b/surfsense_backend/app/automations/services/trigger.py
@@ -14,17 +14,18 @@ from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.schemas.api import TriggerCreate, TriggerUpdate
from app.automations.triggers import get_trigger
from app.automations.triggers.builtin.schedule import compute_next_fire_at
-from app.db import Permission, User, get_async_session
-from app.users import current_active_user
+from app.auth.context import AuthContext
+from app.db import Permission, get_async_session
+from app.users import get_auth_context
from app.utils.rbac import check_permission
class TriggerService:
"""Lifecycle of the ``AutomationTrigger`` sub-resource."""
- def __init__(self, *, session: AsyncSession, user: User) -> None:
+ def __init__(self, *, session: AsyncSession, auth: AuthContext) -> None:
self.session = session
- self.user = user
+ self.auth = auth
async def add(
self, *, automation_id: int, payload: TriggerCreate
@@ -101,7 +102,7 @@ class TriggerService:
)
await check_permission(
self.session,
- self.user,
+ self.auth,
automation.search_space_id,
permission,
f"You don't have permission to {permission.split(':')[1]} automations in this search space",
@@ -144,6 +145,6 @@ def _initial_next_fire(
def get_trigger_service(
session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
+ auth: AuthContext = Depends(get_auth_context),
) -> TriggerService:
- return TriggerService(session=session, user=user)
+ return TriggerService(session=session, auth=auth)
From e5ab0e534210de999b34b56842b74754b8172914 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:28:48 +0530
Subject: [PATCH 12/29] feat: add web PAT API client
---
surfsense_web/contracts/types/pat.types.ts | 30 ++++++++
surfsense_web/hooks/use-api-key.ts | 66 -----------------
surfsense_web/hooks/use-pats.ts | 83 ++++++++++++++++++++++
surfsense_web/lib/apis/pats-api.service.ts | 33 +++++++++
4 files changed, 146 insertions(+), 66 deletions(-)
create mode 100644 surfsense_web/contracts/types/pat.types.ts
delete mode 100644 surfsense_web/hooks/use-api-key.ts
create mode 100644 surfsense_web/hooks/use-pats.ts
create mode 100644 surfsense_web/lib/apis/pats-api.service.ts
diff --git a/surfsense_web/contracts/types/pat.types.ts b/surfsense_web/contracts/types/pat.types.ts
new file mode 100644
index 000000000..a1d50fb4d
--- /dev/null
+++ b/surfsense_web/contracts/types/pat.types.ts
@@ -0,0 +1,30 @@
+import { z } from "zod";
+
+export const pat = z.object({
+ id: z.number(),
+ label: z.string(),
+ prefix: z.string(),
+ expires_at: z.string().nullable(),
+ last_used_at: z.string().nullable(),
+ created_at: z.string(),
+});
+
+export const createPatRequest = z.object({
+ label: z.string().min(1).max(120),
+ expires_in_days: z.number().int().positive().nullable().optional(),
+});
+
+export const createPatResponse = z.object({
+ id: z.number(),
+ label: z.string(),
+ token: z.string(),
+ prefix: z.string(),
+ expires_at: z.string().nullable(),
+});
+
+export const listPatsResponse = z.array(pat);
+export const deletePatResponse = z.void();
+
+export type PersonalAccessToken = z.infer;
+export type CreatePatRequest = z.infer;
+export type CreatedPat = z.infer;
diff --git a/surfsense_web/hooks/use-api-key.ts b/surfsense_web/hooks/use-api-key.ts
deleted file mode 100644
index b50dd65f1..000000000
--- a/surfsense_web/hooks/use-api-key.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { toast } from "sonner";
-import { getBearerToken } from "@/lib/auth-utils";
-import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
-
-interface UseApiKeyReturn {
- apiKey: string | null;
- isLoading: boolean;
- copied: boolean;
- copyToClipboard: () => Promise;
-}
-
-export function useApiKey(): UseApiKeyReturn {
- const [apiKey, setApiKey] = useState(null);
- const [copied, setCopied] = useState(false);
- const [isLoading, setIsLoading] = useState(true);
- const copyTimerRef = useRef | undefined>(undefined);
-
- useEffect(() => {
- return () => {
- if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
- };
- }, []);
-
- useEffect(() => {
- // Load API key from localStorage
- const loadApiKey = () => {
- try {
- const token = getBearerToken();
- setApiKey(token);
- } catch (error) {
- console.error("Error loading API key:", error);
- toast.error("Failed to load API key");
- } finally {
- setIsLoading(false);
- }
- };
-
- // Add a small delay to simulate loading
- const timer = setTimeout(loadApiKey, 500);
- return () => clearTimeout(timer);
- }, []);
-
- const copyToClipboard = useCallback(async () => {
- if (!apiKey) return;
-
- const success = await copyToClipboardUtil(apiKey);
- if (success) {
- setCopied(true);
- toast.success("API key copied to clipboard");
- if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
- copyTimerRef.current = setTimeout(() => {
- setCopied(false);
- }, 2000);
- } else {
- toast.error("Failed to copy API key");
- }
- }, [apiKey]);
-
- return {
- apiKey,
- isLoading,
- copied,
- copyToClipboard,
- };
-}
diff --git a/surfsense_web/hooks/use-pats.ts b/surfsense_web/hooks/use-pats.ts
new file mode 100644
index 000000000..978f26272
--- /dev/null
+++ b/surfsense_web/hooks/use-pats.ts
@@ -0,0 +1,83 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import type {
+ CreatePatRequest,
+ CreatedPat,
+ PersonalAccessToken,
+} from "@/contracts/types/pat.types";
+import { patsApiService } from "@/lib/apis/pats-api.service";
+
+export function usePats() {
+ const [tokens, setTokens] = useState([]);
+ const [createdToken, setCreatedToken] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isMutating, setIsMutating] = useState(false);
+
+ const refresh = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const data = await patsApiService.listPats();
+ setTokens(data);
+ } catch (error) {
+ console.error("Failed to load personal access tokens:", error);
+ toast.error("Failed to load personal access tokens");
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const createToken = useCallback(
+ async (request: CreatePatRequest) => {
+ setIsMutating(true);
+ try {
+ const data = await patsApiService.createPat(request);
+ setCreatedToken(data);
+ await refresh();
+ toast.success("Personal access token created");
+ return data;
+ } catch (error) {
+ console.error("Failed to create personal access token:", error);
+ toast.error("Failed to create personal access token");
+ throw error;
+ } finally {
+ setIsMutating(false);
+ }
+ },
+ [refresh]
+ );
+
+ const deleteToken = useCallback(
+ async (id: number) => {
+ setIsMutating(true);
+ try {
+ await patsApiService.deletePat(id);
+ await refresh();
+ toast.success("Personal access token deleted");
+ } catch (error) {
+ console.error("Failed to delete personal access token:", error);
+ toast.error("Failed to delete personal access token");
+ throw error;
+ } finally {
+ setIsMutating(false);
+ }
+ },
+ [refresh]
+ );
+
+ return {
+ tokens,
+ createdToken,
+ setCreatedToken,
+ isLoading,
+ isMutating,
+ refresh,
+ createToken,
+ deleteToken,
+ };
+}
diff --git a/surfsense_web/lib/apis/pats-api.service.ts b/surfsense_web/lib/apis/pats-api.service.ts
new file mode 100644
index 000000000..c517f1f33
--- /dev/null
+++ b/surfsense_web/lib/apis/pats-api.service.ts
@@ -0,0 +1,33 @@
+import {
+ type CreatePatRequest,
+ createPatRequest,
+ createPatResponse,
+ deletePatResponse,
+ listPatsResponse,
+} from "@/contracts/types/pat.types";
+import { ValidationError } from "../error";
+import { baseApiService } from "./base-api.service";
+
+class PatsApiService {
+ listPats = async () => {
+ return baseApiService.get("/api/v1/pats", listPatsResponse);
+ };
+
+ createPat = async (request: CreatePatRequest) => {
+ const parsedRequest = createPatRequest.safeParse(request);
+ if (!parsedRequest.success) {
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.post("/api/v1/pats", createPatResponse, {
+ body: parsedRequest.data,
+ });
+ };
+
+ deletePat = async (id: number) => {
+ return baseApiService.delete(`/api/v1/pats/${id}`, deletePatResponse);
+ };
+}
+
+export const patsApiService = new PatsApiService();
From 0687561f5bf1067424dfa2cbd74cf1b48471244b Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:29:03 +0530
Subject: [PATCH 13/29] feat: add personal access token settings UI
---
.../components/ApiKeyContent.tsx | 238 ++++++++++++------
surfsense_web/messages/en.json | 4 +-
surfsense_web/messages/es.json | 4 +-
surfsense_web/messages/hi.json | 4 +-
surfsense_web/messages/pt.json | 4 +-
surfsense_web/messages/zh.json | 4 +-
6 files changed, 173 insertions(+), 85 deletions(-)
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx
index 47cdf8f2d..5ac7e83b8 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx
@@ -1,109 +1,197 @@
"use client";
-import { Check, Copy, Info } from "lucide-react";
-import { useTranslations } from "next-intl";
-import { useCallback, useRef, useState } from "react";
+import { Check, Copy, Info, Plus, Trash2 } from "lucide-react";
+import { useCallback, useMemo, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import { useApiKey } from "@/hooks/use-api-key";
+import { usePats } from "@/hooks/use-pats";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
export function ApiKeyContent() {
- const t = useTranslations("userSettings");
- const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
- const [copiedUsage, setCopiedUsage] = useState(false);
- const usageCopyTimeoutRef = useRef>(null);
+ const { tokens, createdToken, setCreatedToken, isLoading, isMutating, createToken, deleteToken } =
+ usePats();
+ const [createOpen, setCreateOpen] = useState(false);
+ const [label, setLabel] = useState("");
+ const [expiresInDays, setExpiresInDays] = useState("");
+ const [copiedToken, setCopiedToken] = useState(false);
- const copyUsageToClipboard = useCallback(async () => {
- const text = `Authorization: Bearer ${apiKey || "YOUR_API_KEY"}`;
- const success = await copyToClipboardUtil(text);
+ const sortedTokens = useMemo(() => tokens, [tokens]);
+
+ const handleCreate = useCallback(async () => {
+ const trimmedLabel = label.trim();
+ if (!trimmedLabel) return;
+
+ await createToken({
+ label: trimmedLabel,
+ expires_in_days: expiresInDays ? Number(expiresInDays) : null,
+ });
+ setLabel("");
+ setExpiresInDays("");
+ setCreateOpen(false);
+ }, [createToken, expiresInDays, label]);
+
+ const copyCreatedToken = useCallback(async () => {
+ if (!createdToken) return;
+ const success = await copyToClipboardUtil(createdToken.token);
if (success) {
- setCopiedUsage(true);
- if (usageCopyTimeoutRef.current) clearTimeout(usageCopyTimeoutRef.current);
- usageCopyTimeoutRef.current = setTimeout(() => setCopiedUsage(false), 2000);
+ setCopiedToken(true);
+ setTimeout(() => setCopiedToken(false), 2000);
}
- }, [apiKey]);
+ }, [createdToken]);
+
+ const handleDelete = useCallback(
+ async (id: number, tokenLabel: string) => {
+ if (!window.confirm(`Delete personal access token "${tokenLabel}"? This cannot be undone.`)) {
+ return;
+ }
+ await deleteToken(id);
+ },
+ [deleteToken]
+ );
return (
- {t("api_key_warning_description")}
+
+ Personal access tokens are long-lived credentials for extensions, Obsidian, and
+ programmatic API clients. Copy a token when you create it; it is shown only once.
+
-
-
{t("your_api_key")}
+
+
+
Personal access tokens
+
+ Expired tokens stay listed until you delete them.
+
+
+
+
+
+
{isLoading ? (
-
-
-
-
-
+
+
+
- ) : apiKey ? (
-
-
-
-
-
+ ) : sortedTokens.length > 0 ? (
+
+ {sortedTokens.map((token) => {
+ const expiresAt = token.expires_at ? new Date(token.expires_at) : null;
+ const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false;
+ return (
+
+
+
+
{token.label}
+ {isExpired ?
Expired : null}
+
+
{token.prefix}...
+
+ Expires: {expiresAt ? expiresAt.toLocaleDateString() : "Never"} · Last used:{" "}
+ {token.last_used_at
+ ? new Date(token.last_used_at).toLocaleString()
+ : "Never"}
+
+
-
-
{copied ? t("copied") : t("copy")}
-
-
+
+ );
+ })}
) : (
- {t("no_api_key")}
+
+ No personal access tokens yet.
+
)}
-
-
{t("usage_title")}
-
{t("usage_description")}
-
-
-
- Authorization: Bearer {apiKey || "YOUR_API_KEY"}
-
+
-
+
+
+
+
+
+
+
+
);
}
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index 866ba4844..6cfee4edf 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -119,9 +119,9 @@
"profile_save": "Save Changes",
"profile_saved": "Profile updated successfully",
"profile_save_error": "Failed to update profile",
- "api_key_nav_label": "API Key",
+ "api_key_nav_label": "API Access",
"api_key_nav_description": "Manage your API access token",
- "api_key_title": "API Key",
+ "api_key_title": "API Access",
"api_key_description": "Use this key to authenticate API requests",
"api_key_warning_description": "Your API key grants full access to your account. Never share it publicly or commit it to version control.",
"your_api_key": "Your API Key",
diff --git a/surfsense_web/messages/es.json b/surfsense_web/messages/es.json
index f7755b47e..06b309df1 100644
--- a/surfsense_web/messages/es.json
+++ b/surfsense_web/messages/es.json
@@ -119,9 +119,9 @@
"profile_save": "Guardar cambios",
"profile_saved": "Perfil actualizado correctamente",
"profile_save_error": "Error al actualizar el perfil",
- "api_key_nav_label": "Clave API",
+ "api_key_nav_label": "Acceso API",
"api_key_nav_description": "Administra tu token de acceso a la API",
- "api_key_title": "Clave API",
+ "api_key_title": "Acceso API",
"api_key_description": "Usa esta clave para autenticar las solicitudes de la API",
"api_key_warning_description": "Tu clave API otorga acceso completo a tu cuenta. Nunca la compartas públicamente ni la incluyas en el control de versiones.",
"your_api_key": "Tu clave API",
diff --git a/surfsense_web/messages/hi.json b/surfsense_web/messages/hi.json
index 038555f1e..73a025803 100644
--- a/surfsense_web/messages/hi.json
+++ b/surfsense_web/messages/hi.json
@@ -119,9 +119,9 @@
"profile_save": "परिवर्तन सहेजें",
"profile_saved": "प्रोफ़ाइल सफलतापूर्वक अपडेट की गई",
"profile_save_error": "प्रोफ़ाइल अपडेट करने में विफल",
- "api_key_nav_label": "API कुंजी",
+ "api_key_nav_label": "API एक्सेस",
"api_key_nav_description": "अपना API एक्सेस टोकन प्रबंधित करें",
- "api_key_title": "API कुंजी",
+ "api_key_title": "API एक्सेस",
"api_key_description": "API अनुरोधों को प्रमाणित करने के लिए इस कुंजी का उपयोग करें",
"api_key_warning_description": "आपकी API कुंजी आपके खाते तक पूर्ण पहुंच प्रदान करती है। इसे कभी सार्वजनिक रूप से साझा न करें या संस्करण नियंत्रण में शामिल न करें।",
"your_api_key": "आपकी API कुंजी",
diff --git a/surfsense_web/messages/pt.json b/surfsense_web/messages/pt.json
index bcba8f70c..00b8242f7 100644
--- a/surfsense_web/messages/pt.json
+++ b/surfsense_web/messages/pt.json
@@ -119,9 +119,9 @@
"profile_save": "Salvar alterações",
"profile_saved": "Perfil atualizado com sucesso",
"profile_save_error": "Falha ao atualizar o perfil",
- "api_key_nav_label": "Chave API",
+ "api_key_nav_label": "Acesso API",
"api_key_nav_description": "Gerencie seu token de acesso à API",
- "api_key_title": "Chave API",
+ "api_key_title": "Acesso API",
"api_key_description": "Use esta chave para autenticar solicitações da API",
"api_key_warning_description": "Sua chave API concede acesso total à sua conta. Nunca a compartilhe publicamente nem a inclua no controle de versão.",
"your_api_key": "Sua chave API",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 5fea60eb8..fd4147e66 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -119,9 +119,9 @@
"profile_save": "保存更改",
"profile_saved": "个人资料已成功更新",
"profile_save_error": "无法更新个人资料",
- "api_key_nav_label": "API密钥",
+ "api_key_nav_label": "API访问",
"api_key_nav_description": "管理您的API访问令牌",
- "api_key_title": "API密钥",
+ "api_key_title": "API访问",
"api_key_description": "使用此密钥验证API请求",
"api_key_warning_description": "您的API密钥可以完全访问您的账户。请勿公开分享或提交到版本控制。",
"your_api_key": "您的API密钥",
From 1cc72a47be6514fc721005dc386c48498621e5ec Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:29:15 +0530
Subject: [PATCH 14/29] feat: create PATs from Obsidian connector setup
---
.../components/obsidian-connect-form.tsx | 57 ++++++++++++-------
1 file changed, 36 insertions(+), 21 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx
index 695e97d7b..7ec39803b 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx
@@ -2,10 +2,12 @@
import { Check, Copy, Info } from "lucide-react";
import type { FC } from "react";
+import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector";
-import { useApiKey } from "@/hooks/use-api-key";
+import { usePats } from "@/hooks/use-pats";
+import { copyToClipboard } from "@/lib/utils";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
@@ -26,13 +28,23 @@ const PLUGIN_RELEASES_URL =
* nothing to validate or persist from this side.
*/
export const ObsidianConnectForm: FC
= ({ onBack }) => {
- const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
+ const { createdToken, isMutating, createToken } = usePats();
+ const [copied, setCopied] = useState(false);
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
onBack();
};
+ const createAndCopyToken = async () => {
+ const token = await createToken({ label: "Obsidian plugin", expires_in_days: null });
+ const success = await copyToClipboard(token.token);
+ if (success) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
return (
{/* Form is intentionally empty so the footer Connect button is a no-op
@@ -82,48 +94,51 @@ export const ObsidianConnectForm: FC
= ({ onBack }) => {
- {/* Step 2 — Copy API key */}
+ {/* Step 2 — Create PAT */}
2
- Copy your API key
+
+ Create a personal access token
+
- Paste this into the plugin's API token setting.
- The token expires after 24 hours. Long-lived personal access tokens are coming in a
- future release.
+ Create a token and paste it into the plugin's{" "}
+ API token setting. The token is shown only once.
- {isLoading ? (
-
- ) : apiKey ? (
+ {createdToken ? (
- {apiKey}
+ {createdToken.token}
) : (
-
- No API key available — try refreshing the page.
-
+
)}
From 8af4a3f9d55b72e7d7e59d7b8e04a78d196a3b10 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 19 Jun 2026 20:29:30 +0530
Subject: [PATCH 15/29] feat: update extension clients for PAT auth
---
.../routes/pages/ApiKeyForm.tsx | 16 ++++++++--------
surfsense_obsidian/README.md | 7 +------
surfsense_obsidian/src/api-client.ts | 4 ++--
surfsense_obsidian/src/main.ts | 2 +-
surfsense_obsidian/src/settings.ts | 2 +-
5 files changed, 13 insertions(+), 18 deletions(-)
diff --git a/surfsense_browser_extension/routes/pages/ApiKeyForm.tsx b/surfsense_browser_extension/routes/pages/ApiKeyForm.tsx
index 537eba3da..d045d8129 100644
--- a/surfsense_browser_extension/routes/pages/ApiKeyForm.tsx
+++ b/surfsense_browser_extension/routes/pages/ApiKeyForm.tsx
@@ -16,7 +16,7 @@ const ApiKeyForm = () => {
const validateForm = () => {
if (!apiKey) {
- setError("API key is required");
+ setError("Personal access token is required");
return false;
}
setError("");
@@ -39,11 +39,11 @@ const ApiKeyForm = () => {
setLoading(false);
if (response.ok) {
- // Store the API key as the token
+ // Store the PAT as the bearer token for existing background handlers.
await storage.set("token", apiKey);
navigation("/");
} else {
- setError("Invalid API key. Please check and try again.");
+ setError("Invalid personal access token. Please check and try again.");
}
} catch (error) {
setLoading(false);
@@ -67,15 +67,15 @@ const ApiKeyForm = () => {
-
Enter your API Key
+
Enter your personal access token
- Your API key connects this extension to the SurfSense.
+ Your personal access token connects this extension to SurfSense.