Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7b981b6
refactor: simplify error screen layout and remove unused components
AnishSarkar22 Jun 19, 2026
4463990
feat: add PAT storage and API access fields
AnishSarkar22 Jun 19, 2026
cddfb36
feat: resolve auth context from sessions and PATs
AnishSarkar22 Jun 19, 2026
608facd
feat: add personal access token API routes
AnishSarkar22 Jun 19, 2026
54a3ba1
feat: add search space API access controls
AnishSarkar22 Jun 19, 2026
630880b
feat: add API access toggle to search space settings
AnishSarkar22 Jun 19, 2026
7e8d26f
refactor: route authorization through auth context
AnishSarkar22 Jun 19, 2026
493e8d5
feat: enforce API access for knowledge resources
AnishSarkar22 Jun 19, 2026
70a0828
feat: enforce API access for chat routes
AnishSarkar22 Jun 19, 2026
7ec6fa4
feat: enforce API access for integration routes
AnishSarkar22 Jun 19, 2026
096dea4
refactor: pass auth context through automations
AnishSarkar22 Jun 19, 2026
e5ab0e5
feat: add web PAT API client
AnishSarkar22 Jun 19, 2026
0687561
feat: add personal access token settings UI
AnishSarkar22 Jun 19, 2026
1cc72a4
feat: create PATs from Obsidian connector setup
AnishSarkar22 Jun 19, 2026
8af4a3f
feat: update extension clients for PAT auth
AnishSarkar22 Jun 19, 2026
6fd3f85
refactor: streamline auth context usage across chat and automation ro…
AnishSarkar22 Jun 19, 2026
49b5247
refactor: unify authentication handling by replacing current_active_u…
AnishSarkar22 Jun 19, 2026
2315b2f
feat(auth): add PAT fail-closed bootstrap allowlist
AnishSarkar22 Jun 19, 2026
1f9cf32
feat(auth): require sessions for user-scoped routes
AnishSarkar22 Jun 19, 2026
3a0cd8c
fix(models): require sessions for personal connection writes
AnishSarkar22 Jun 19, 2026
cf84087
fix(connectors): gate folder listings for PAT access
AnishSarkar22 Jun 19, 2026
b3fa96b
test(auth): cover PAT fail-closed authorization
AnishSarkar22 Jun 19, 2026
6dd8bd4
refactor(routes): replace user variable with auth context in thread s…
AnishSarkar22 Jun 19, 2026
8e50871
refactor(routes): replace user variable with auth context in search s…
AnishSarkar22 Jun 19, 2026
14cb0a2
refactor(routes): update document file access functions to use auth c…
AnishSarkar22 Jun 19, 2026
af5a112
refactor(auth): replace user variable with auth context in integratio…
AnishSarkar22 Jun 19, 2026
1e8baa1
refactor(routes): replace user variable with auth context in tests
AnishSarkar22 Jun 19, 2026
fd31ac3
Merge remote-tracking branch 'upstream/dev' into feat/api-key
AnishSarkar22 Jun 20, 2026
96c1dd9
chore(migration): renamed the migration
AnishSarkar22 Jun 20, 2026
3695e1d
Merge remote-tracking branch 'upstream/dev' into feat/api-key
AnishSarkar22 Jun 23, 2026
8a6c30c
fix(chore): rename alembic migration for PATs
AnishSarkar22 Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions surfsense_backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ SECRET_KEY=SECRET
# JWT Token Lifetimes (optional, defaults shown)
# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # 1 day
# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 2 weeks
# Personal Access Tokens (PATs). Empty/unset = no maximum; users may create
# never-expiring PATs. When set, PAT creation requires an expiry <= this many days.
# PAT_MAX_EXPIRY_DAYS=

NEXT_FRONTEND_URL=http://localhost:3000

Expand Down
83 changes: 83 additions & 0 deletions surfsense_backend/alembic/versions/166_add_pat_and_api_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Add personal access tokens and search-space API access gate.

Revision ID: 166
Revises: 165
"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "166"
down_revision: str | None = "165"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS personal_access_tokens (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL,
token_prefix VARCHAR(16) NOT NULL,
label VARCHAR NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
"""
)

op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_personal_access_tokens_token_hash "
"ON personal_access_tokens (token_hash)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_user_id "
"ON personal_access_tokens (user_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_id "
"ON personal_access_tokens (id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_created_at "
"ON personal_access_tokens (created_at)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_expires_at "
"ON personal_access_tokens (expires_at)"
)

bind = op.get_bind()
api_access_column_exists = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'searchspaces'
AND column_name = 'api_access_enabled'
)
"""
)
).scalar()

op.execute(
"ALTER TABLE searchspaces ADD COLUMN IF NOT EXISTS "
"api_access_enabled BOOLEAN NOT NULL DEFAULT false"
)

if not api_access_column_exists:
op.execute("UPDATE searchspaces SET api_access_enabled = true")


def downgrade() -> None:
op.execute(
"ALTER TABLE searchspaces DROP COLUMN IF EXISTS api_access_enabled"
)
op.execute("DROP TABLE IF EXISTS personal_access_tokens")
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
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 (
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@
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
from app.db import async_session_maker
from app.utils.content_utils import extract_text_content

from .prompt import build_draft_prompt
Expand All @@ -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.

Expand All @@ -56,8 +58,6 @@ def create_create_automation_tool(
``AsyncSession`` is opened per call to avoid stale sessions on
compiled-agent cache hits (same pattern as the Notion / memory tools).
"""
uid = UUID(user_id) if isinstance(user_id, str) else user_id

@tool
async def create_automation(intent: str, runtime: ToolRuntime) -> dict[str, Any]:
"""Draft + save an automation from a natural-language intent.
Expand Down Expand Up @@ -165,14 +165,17 @@ async def create_automation(intent: str, runtime: ToolRuntime) -> dict[str, Any]
"issues": _format_validation_issues(exc),
}

if auth_context is None:
logger.error(
"create_automation called without AuthContext; refusing to persist"
)
return {
"status": "error",
"message": "authorization context missing for automation creation",
}

async with async_session_maker() as session:
user = await session.get(User, uid)
if user is None:
return {
"status": "error",
"message": "user not found in this session",
}
service = AutomationService(session=session, user=user)
service = AutomationService(session=session, auth=auth_context)
created = await service.create(final_validated)
return {
"status": "saved",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)

Expand Down
9 changes: 5 additions & 4 deletions surfsense_backend/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@
close_checkpointer,
setup_checkpointer_tables,
)
from app.auth.context import AuthContext
from app.config import (
config,
initialize_image_gen_router,
initialize_llm_router,
initialize_openrouter_integration,
initialize_pricing_registration,
)
from app.db import User, create_db_and_tables, get_async_session
from app.db import create_db_and_tables, get_async_session
from app.exceptions import GENERIC_5XX_MESSAGE, ISSUES_URL, SurfSenseError
from app.gateway.byo_long_poll import (
start_byo_long_poll_supervisors,
Expand All @@ -55,7 +56,7 @@
from app.routes.auth_routes import router as auth_router
from app.schemas import UserCreate, UserRead, UserUpdate
from app.session_events import register_session_hooks
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
from app.users import SECRET, allow_any_principal, auth_backend, fastapi_users
from app.utils.perf import log_system_snapshot

_error_logger = logging.getLogger("surfsense.errors")
Expand Down Expand Up @@ -1032,7 +1033,7 @@ async def readiness_check():

@app.get("/verify-token")
async def authenticated_route(
user: User = Depends(current_active_user),
auth: AuthContext = Depends(allow_any_principal),
session: AsyncSession = Depends(get_async_session),
):
return {"message": "Token is valid"}
return {"message": "Token is valid", "method": auth.method}
1 change: 1 addition & 0 deletions surfsense_backend/app/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Authentication principals and helpers."""
38 changes: 38 additions & 0 deletions surfsense_backend/app/auth/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from app.db import PersonalAccessToken, User

AuthMethod = Literal["session", "pat", "system"]


@dataclass(frozen=True)
class AuthContext:
"""Typed principal for authorization decisions."""

user: User
method: AuthMethod
pat: PersonalAccessToken | None = None
source: str | None = None

@classmethod
def session(cls, user: User) -> AuthContext:
return cls(user=user, method="session")

@classmethod
def pat_auth(cls, user: User, pat: PersonalAccessToken) -> AuthContext:
return cls(user=user, method="pat", pat=pat)

@classmethod
def system(cls, user: User, source: str) -> AuthContext:
return cls(user=user, method="system", source=source)

@property
def is_gated(self) -> bool:
return self.method == "pat"

@property
def is_session(self) -> bool:
return self.method == "session"
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
16 changes: 9 additions & 7 deletions surfsense_backend/app/automations/services/automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,19 @@
)
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."""
Expand Down Expand Up @@ -235,7 +237,7 @@ def _assert_selected_models_billable(self, models: AutomationModels) -> None:
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",
Expand Down Expand Up @@ -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)
15 changes: 8 additions & 7 deletions surfsense_backend/app/automations/services/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@

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,
Expand Down Expand Up @@ -63,7 +64,7 @@ async def _authorize(self, automation_id: int, permission: str) -> Automation:
)
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",
Expand All @@ -73,6 +74,6 @@ async def _authorize(self, automation_id: int, permission: str) -> Automation:

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)
Loading
Loading