From 2315b2f344c030516775d4a9c2f8ec029627bdd2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:57:37 +0530 Subject: [PATCH] feat(auth): add PAT fail-closed bootstrap allowlist --- surfsense_backend/app/app.py | 4 ++-- .../app/routes/obsidian_plugin_routes.py | 4 ++-- .../app/routes/search_spaces_routes.py | 11 +++-------- surfsense_backend/app/users.py | 13 +++++++------ 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 6ee89e86c..e6aa2fa3e 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -56,7 +56,7 @@ from app.routes import router as crud_router 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, fastapi_users, get_auth_context +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") @@ -1033,7 +1033,7 @@ async def readiness_check(): @app.get("/verify-token") async def authenticated_route( - auth: AuthContext = Depends(get_auth_context), + auth: AuthContext = Depends(allow_any_principal), session: AsyncSession = Depends(get_async_session), ): return {"message": "Token is valid", "method": auth.method} diff --git a/surfsense_backend/app/routes/obsidian_plugin_routes.py b/surfsense_backend/app/routes/obsidian_plugin_routes.py index 42ac110d3..56623d61a 100644 --- a/surfsense_backend/app/routes/obsidian_plugin_routes.py +++ b/surfsense_backend/app/routes/obsidian_plugin_routes.py @@ -53,7 +53,7 @@ from app.services.obsidian_plugin_indexer import ( upsert_note, ) from app.tasks.celery_tasks.obsidian_tasks import index_obsidian_attachment_task -from app.users import get_auth_context +from app.users import allow_any_principal, get_auth_context from app.utils.rbac import check_search_space_access logger = logging.getLogger(__name__) @@ -255,7 +255,7 @@ async def _ensure_search_space_access( @router.get("/health", response_model=HealthResponse) async def obsidian_health( - _auth: AuthContext = Depends(get_auth_context), + _auth: AuthContext = Depends(allow_any_principal), ) -> HealthResponse: """Return the API contract handshake; plugin caches it per onload.""" return HealthResponse( diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index ad5d5cff3..33ef188bc 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -11,7 +11,6 @@ from app.db import ( SearchSpace, SearchSpaceMembership, SearchSpaceRole, - User, get_async_session, get_default_roles_config, ) @@ -22,7 +21,7 @@ from app.schemas import ( SearchSpaceUpdate, SearchSpaceWithStats, ) -from app.users import get_auth_context +from app.users import allow_any_principal, get_auth_context, require_session_context from app.utils.rbac import check_permission, check_search_space_access logger = logging.getLogger(__name__) @@ -76,7 +75,7 @@ async def create_default_roles_and_membership( async def create_search_space( search_space: SearchSpaceCreate, session: AsyncSession = Depends(get_async_session), - auth: AuthContext = Depends(get_auth_context), + auth: AuthContext = Depends(require_session_context), ): user = auth.user try: @@ -111,7 +110,7 @@ async def read_search_spaces( limit: int = 200, owned_only: bool = False, session: AsyncSession = Depends(get_async_session), - auth: AuthContext = Depends(get_auth_context), + auth: AuthContext = Depends(allow_any_principal), ): user = auth.user """ @@ -209,7 +208,6 @@ async def read_search_space( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Get a specific search space by ID. Requires SETTINGS_VIEW permission or membership. @@ -243,7 +241,6 @@ async def update_search_space( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Update a search space. Requires SETTINGS_UPDATE permission. @@ -289,7 +286,6 @@ async def update_search_space_api_access( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Toggle programmatic API/PAT access for a search space. Requires API_ACCESS_MANAGE permission. @@ -373,7 +369,6 @@ async def delete_search_space( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - user = auth.user """ Delete a search space. Requires SETTINGS_DELETE permission (only owners have this by default). diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index e54941d4a..d668dba45 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -349,15 +349,16 @@ async def get_auth_context( return AuthContext.session(user) -async def current_active_user( +async def allow_any_principal( auth: AuthContext = Depends(get_auth_context), -) -> User: - """Compatibility wrapper for identity-only routes. +) -> AuthContext: + """Allow either session or PAT principals for bootstrap probes only. - Do not use this for space-scoped authorization or session-grade account - actions. Those should depend on get_auth_context or require_session_context. + Routes using this dependency intentionally have no search-space gate. + Adding a new call site is a security decision and must be covered by + the fail-closed PAT allowlist test. """ - return auth.user + return auth async def require_session_context(