Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions surfsense_backend/app/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
109 changes: 109 additions & 0 deletions surfsense_backend/app/services/smb_auto_join.py
Original file line number Diff line number Diff line change
@@ -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,
)
17 changes: 17 additions & 0 deletions surfsense_backend/app/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading