diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 4eda2741d0..ca7d7d47d5 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -320,6 +320,13 @@ def is_cloud(cls) -> bool: # claim) instead of a full email address. DEFAULT_EMAIL_DOMAIN = os.getenv("DEFAULT_EMAIL_DOMAIN", "askii.ai") + # Portal hostname prefix (e.g. fossil for fossil.local...). + SMB_NAME = (os.getenv("SMB_NAME") or "").strip() + # Default shared search space name (SSO auto-join). Defaults to SMB_NAME. + SMB_DEFAULT_WORKSPACE_NAME = ( + os.getenv("SMB_DEFAULT_WORKSPACE_NAME") or os.getenv("SMB_NAME") or "" + ).strip() + # Google OAuth GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID") GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") diff --git a/surfsense_backend/app/services/smb_auto_join.py b/surfsense_backend/app/services/smb_auto_join.py new file mode 100644 index 0000000000..a2ae182f5d --- /dev/null +++ b/surfsense_backend/app/services/smb_auto_join.py @@ -0,0 +1,109 @@ +"""Ensure SSO users are members of the shared SMB SearchSpace (SMB_DEFAULT_WORKSPACE_NAME).""" + +from __future__ import annotations + +import logging +import uuid + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.config import config +from app.db import ( + SearchSpace, + SearchSpaceMembership, + SearchSpaceRole, + async_session_maker, +) + +logger = logging.getLogger(__name__) + + +async def auto_join_smb_search_space(user_id: uuid.UUID) -> None: + """ + ``_auto_join_workspace``: match the search space whose + name equals ``SMB_DEFAULT_WORKSPACE_NAME`` or ``SMB_NAME``. If none exists, + do nothing (no fallback to another space). + + Uses the default invite role (Editor) when absent. Idempotent. + Safe when auth uses Bearer JWT only: runs from ``current_active_user`` as well. + """ + smb_slug = ( + getattr(config, "SMB_DEFAULT_WORKSPACE_NAME", None) + or getattr(config, "SMB_NAME", "") + or "" + ) + if isinstance(smb_slug, str): + smb_slug = smb_slug.strip() + if not smb_slug: + return + + async with async_session_maker() as session: + not_deleting = ~SearchSpace.name.startswith("[DELETING] ") + space_result = await session.execute( + select(SearchSpace) + .where(SearchSpace.name == smb_slug, not_deleting) + .order_by(SearchSpace.id.asc()) + .limit(1) + ) + space = space_result.scalars().first() + if space is None: + logger.debug( + "SMB auto-join: no search space named %r — skipping", + smb_slug, + ) + return + + existing_member = await session.execute( + select(SearchSpaceMembership.id).where( + SearchSpaceMembership.user_id == user_id, + SearchSpaceMembership.search_space_id == space.id, + ) + ) + if existing_member.scalars().first() is not None: + return + + role_result = await session.execute( + select(SearchSpaceRole).where( + SearchSpaceRole.search_space_id == space.id, + SearchSpaceRole.is_default == True, # noqa: E712 + ) + ) + role = role_result.scalars().first() + if role is None: + role_result = await session.execute( + select(SearchSpaceRole).where( + SearchSpaceRole.search_space_id == space.id, + SearchSpaceRole.name == "Editor", + ) + ) + role = role_result.scalars().first() + if role is None: + logger.warning( + "SMB auto-join: no default or Editor role for search space %s — skipping", + space.id, + ) + return + + membership = SearchSpaceMembership( + user_id=user_id, + search_space_id=space.id, + role_id=role.id, + is_owner=False, + ) + session.add(membership) + try: + await session.commit() + logger.info( + "SMB auto-join: joined user %s to search space %s (role=%s)", + user_id, + space.id, + role.name, + ) + except IntegrityError: + await session.rollback() + logger.debug( + "SMB auto-join: race for user %s / space %s — already joined", + user_id, + space.id, + ) diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index e35cb99911..0fc8b6f722 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -16,6 +16,7 @@ from sqlalchemy import update from app.config import config +from app.services.smb_auto_join import auto_join_smb_search_space from app.db import ( Prompt, SearchSpace, @@ -312,11 +313,27 @@ async def current_active_user( mPass proxy auth is active). Falls back to JWT Bearer token validation so existing email/password and Google OAuth flows continue to work when proxy auth is disabled. + + SMB shared SearchSpace membership is enforced here — not only in + ProxyAuthMiddleware — because after proxy-login the browser usually sends Bearer + JWT without X-Auth-Request-Email, so middleware alone would never run auto-join. """ proxy_user = getattr(request.state, "proxy_user", None) if proxy_user is not None: + try: + await auto_join_smb_search_space(proxy_user.id) + except Exception: + logger.exception( + "SMB auto-join failed for proxy session user %s", proxy_user.id + ) return proxy_user if jwt_user is not None: + try: + await auto_join_smb_search_space(jwt_user.id) + except Exception: + logger.exception( + "SMB auto-join failed for JWT user %s", jwt_user.id + ) return jwt_user raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED,