From 17ca9401bec39edcdd2f6729c0cdc85187df4141 Mon Sep 17 00:00:00 2001
From: _ <50262751+hunzlahmalik@users.noreply.github.com>
Date: Wed, 3 Jun 2026 11:06:15 +0500
Subject: [PATCH 01/11] feat(surfsense): auto-provision org-space LiteLLM key
on first SMB admin login
Extend the existing per-user Askii LiteLLM auto-provisioning so the shared
SMB/Organization search space also gets a key automatically, the first time
its is_owner admin logs in. The whole team then has AI features in the shared
space with no manual config.
Mirrors the personal flow as a direct analogue:
- New SearchSpace.litellm_auto_provisioned_at one-shot marker (migration
moneta_002), the SearchSpace-scoped counterpart of the per-user marker.
- ensure_org_litellm_keys / ensure_org_litellm_keys_for_admin in
litellm_provisioning.py: find the SMB space by name, gate on is_owner
(is_search_space_owner), mint a fresh Askii key, wire the 4 SearchSpace LLM
FKs, stamp the marker -- all under a begin_nested() SAVEPOINT with
SELECT ... FOR UPDATE for exactly-once under concurrent logins.
- Reuses should_auto_provision / _provision_via_askii / _build_config_rows
unchanged; personal path untouched.
- smb_auto_join: extracted reusable find_smb_search_space(session) +
_smb_workspace_slug(); auto_join now reuses them.
- on_after_login wires the org call after the personal one, reading the mPass
token once and committing the two steps on independent boundaries.
Best-effort + retry: any failure (transient Askii error, missing token, no SMB
space yet, not-yet-admin) leaves the marker NULL and retries on the admin's
next login. One-shot once stamped.
Tests: 21 new org cases (core + wrapper) + updated on_after_login commit tests
+ new org-wiring test. 52 passing; ruff clean; alembic single head
(143 -> moneta_001 -> moneta_002).
Co-Authored-By: Claude Opus 4.8
---
surfsense_backend/CLAUDE.md | 2 +-
...ellm_auto_provisioned_at_to_searchspace.py | 69 +++
surfsense_backend/app/db.py | 6 +
.../app/services/litellm_provisioning.py | 255 ++++++++++
.../app/services/smb_auto_join.py | 55 ++-
surfsense_backend/app/users.py | 31 +-
.../tests/unit/test_litellm_provisioning.py | 463 +++++++++++++++++-
.../unit/test_user_manager_on_after_login.py | 64 ++-
8 files changed, 923 insertions(+), 22 deletions(-)
create mode 100644 surfsense_backend/alembic/versions/moneta_002_add_litellm_auto_provisioned_at_to_searchspace.py
diff --git a/surfsense_backend/CLAUDE.md b/surfsense_backend/CLAUDE.md
index c01f5db6e..4c52390c2 100644
--- a/surfsense_backend/CLAUDE.md
+++ b/surfsense_backend/CLAUDE.md
@@ -19,7 +19,7 @@ down_revision: str | None = ""
1. Find the latest revision in `alembic/versions/` (either upstream integer or
prior `moneta_*` — whichever is more recent).
-2. Pick the next available `moneta_NNN`. Current next: `moneta_002`.
+2. Pick the next available `moneta_NNN`. Current next: `moneta_003`.
3. Name the file `moneta_NNN_.py`.
4. Set `revision = "moneta_NNN"`, `down_revision = ""`.
diff --git a/surfsense_backend/alembic/versions/moneta_002_add_litellm_auto_provisioned_at_to_searchspace.py b/surfsense_backend/alembic/versions/moneta_002_add_litellm_auto_provisioned_at_to_searchspace.py
new file mode 100644
index 000000000..709f43609
--- /dev/null
+++ b/surfsense_backend/alembic/versions/moneta_002_add_litellm_auto_provisioned_at_to_searchspace.py
@@ -0,0 +1,69 @@
+"""moneta_002_add_litellm_auto_provisioned_at_to_searchspace
+
+Revision ID: moneta_002
+Revises: moneta_001
+Create Date: 2026-06-02
+
+Fork-migration convention
+-------------------------
+Moneta-fork migration (``moneta_NNN`` namespace) — see
+``surfsense_backend/CLAUDE.md`` § "Fork migrations". Chains off ``moneta_001``.
+Next fork migration should be ``moneta_003``.
+
+Adds ``litellm_auto_provisioned_at`` (nullable timestamp) to the
+``searchspaces`` table. This is the one-shot marker for **org-space** Askii
+LiteLLM auto-provisioning (``app.services.litellm_provisioning``): when the
+``is_owner`` admin of the shared SMB/Organization space logs in, a fresh Askii
+key is minted and the four ``SearchSpace.*_id`` LLM FKs are wired onto that
+space — exactly once.
+
+- NULL → org space has never been auto-provisioned → eligible.
+- non-NULL → provisioned once at this time → permanently ineligible,
+ regardless of whether the four config rows still exist.
+
+It is the ``SearchSpace``-scoped analogue of the user-scoped
+``user.litellm_auto_provisioned_at`` marker added in ``moneta_001``.
+
+No backfill: org-space auto-provisioning did not exist before this migration,
+so there are no already-provisioned org spaces to stamp.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "moneta_002"
+down_revision: str | None = "moneta_001"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ existing_columns = [
+ col["name"] for col in sa.inspect(conn).get_columns("searchspaces")
+ ]
+
+ if "litellm_auto_provisioned_at" not in existing_columns:
+ op.add_column(
+ "searchspaces",
+ sa.Column(
+ "litellm_auto_provisioned_at",
+ sa.TIMESTAMP(timezone=True),
+ nullable=True,
+ ),
+ )
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ existing_columns = [
+ col["name"] for col in sa.inspect(conn).get_columns("searchspaces")
+ ]
+
+ if "litellm_auto_provisioned_at" in existing_columns:
+ op.drop_column("searchspaces", "litellm_auto_provisioned_at")
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index c0f83d2c0..a51652149 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -1445,6 +1445,12 @@ class SearchSpace(BaseModel, TimestampMixin):
Integer, nullable=True, default=0
) # For vision/screenshot analysis, defaults to Auto mode
+ # Org-space one-shot LiteLLM auto-provision marker (Moneta fork).
+ # NULL → eligible; non-NULL → provisioned once, permanently ineligible.
+ # SearchSpace-scoped analogue of User.litellm_auto_provisioned_at; stamped
+ # when the is_owner admin of the shared SMB/Organization space logs in.
+ litellm_auto_provisioned_at = Column(TIMESTAMP(timezone=True), nullable=True)
+
ai_file_sort_enabled = Column(
Boolean, nullable=False, default=False, server_default="false"
)
diff --git a/surfsense_backend/app/services/litellm_provisioning.py b/surfsense_backend/app/services/litellm_provisioning.py
index 937875056..6bd33bdf0 100644
--- a/surfsense_backend/app/services/litellm_provisioning.py
+++ b/surfsense_backend/app/services/litellm_provisioning.py
@@ -122,6 +122,8 @@
VisionLLMConfig,
VisionProvider,
)
+from app.services.smb_auto_join import find_smb_search_space
+from app.utils.rbac import is_search_space_owner
if TYPE_CHECKING:
import httpx
@@ -541,6 +543,77 @@ async def _persist_and_wire(
user.litellm_auto_provisioned_at = now
+async def _check_space_marker_under_lock(
+ session: AsyncSession,
+ search_space_id: int,
+) -> None:
+ """Acquire a row lock on the org ``SearchSpace`` and re-check its marker.
+
+ SearchSpace-scoped analogue of :func:`_check_marker_under_lock`. Caller
+ MUST run this inside ``session.begin_nested()`` so the lock (and any
+ rollback) is scoped to the SAVEPOINT.
+
+ Raises :class:`_RaceLossError` when a sibling request stamped
+ ``SearchSpace.litellm_auto_provisioned_at`` while our Askii call was in
+ flight. Returns ``None`` when this caller holds the lock and the marker is
+ still NULL — caller proceeds with the insert.
+ """
+ result = await session.execute(
+ select(SearchSpace.litellm_auto_provisioned_at)
+ .where(SearchSpace.id == search_space_id)
+ .with_for_update()
+ )
+ if result.scalar_one_or_none() is not None:
+ raise _RaceLossError
+
+
+async def _persist_and_wire_space(
+ *,
+ session: AsyncSession,
+ search_space: SearchSpace,
+ search_space_id: int,
+ rows: tuple[NewLLMConfig, NewLLMConfig, ImageGenerationConfig, VisionLLMConfig],
+) -> None:
+ """Add the four rows, wire the SearchSpace FKs, stamp the space marker.
+
+ SearchSpace-scoped analogue of :func:`_persist_and_wire`. Flush-only (no
+ commit, no rollback) — caller owns transaction boundaries. Because the
+ one-shot marker lives on the same ``searchspaces`` row as the four FKs, a
+ single ``UPDATE`` wires the FKs and stamps the marker together (the
+ personal path needs two UPDATEs since its marker is on the ``user`` table).
+ Writes go through an explicit ``UPDATE ... WHERE`` rather than ORM attribute
+ mutation because ``search_space`` is frequently detached from this
+ service's session; the Python object is mirrored afterwards so the caller's
+ in-request view stays consistent.
+ """
+ agent_row, doc_summary_row, image_row, vision_row = rows
+ for row in rows:
+ session.add(row)
+ await session.flush() # populate .id on each new row
+
+ now = datetime.now(UTC)
+ await session.execute(
+ update(SearchSpace)
+ .where(SearchSpace.id == search_space_id)
+ .values(
+ agent_llm_id=agent_row.id,
+ document_summary_llm_id=doc_summary_row.id,
+ image_generation_config_id=image_row.id,
+ vision_llm_config_id=vision_row.id,
+ litellm_auto_provisioned_at=now,
+ )
+ )
+
+ # Mirror onto the Python object (not load-bearing — the UPDATE above owns
+ # persistence). Keeps the caller's in-memory view consistent and lets tests
+ # assert against attributes without a DB round-trip.
+ search_space.agent_llm_id = agent_row.id
+ search_space.document_summary_llm_id = doc_summary_row.id
+ search_space.image_generation_config_id = image_row.id
+ search_space.vision_llm_config_id = vision_row.id
+ search_space.litellm_auto_provisioned_at = now
+
+
# ---------------------------------------------------------------------------
# Upstream Askii call
# ---------------------------------------------------------------------------
@@ -718,6 +791,186 @@ async def ensure_personal_litellm_keys_for_user(
return False
+async def ensure_org_litellm_keys(
+ *,
+ session: AsyncSession,
+ admin_user: User,
+ org_search_space: SearchSpace,
+ access_token: str | None,
+ cfg: Config,
+ http_client: httpx.AsyncClient | None = None,
+) -> bool:
+ """Auto-provision a fresh Askii key onto the shared Organization space.
+
+ Org-space analogue of :func:`ensure_personal_litellm_keys`. One-shot per
+ org space via ``org_search_space.litellm_auto_provisioned_at`` (the
+ SearchSpace marker, NOT the per-user one): the first ``is_owner`` admin to
+ log in mints one fresh upstream Askii key under their mPass token and wires
+ the four ``SearchSpace.*_id`` FKs onto the org space; subsequent
+ admins/logins short-circuit on the marker.
+
+ Same shape and guarantees as the personal path: the outbound Askii call
+ happens *without* a DB lock; the marker re-check + writes run inside a
+ ``session.begin_nested()`` SAVEPOINT with ``SELECT ... FOR UPDATE`` on the
+ org ``SearchSpace`` row (race-loss → ``_RaceLossError`` → SAVEPOINT
+ rollback → return ``True``, orphaned upstream key auto-expires).
+
+ Best-effort contract: never raises; returns ``False`` on any skip/failure
+ so the next-login retry path tries again. Does NOT commit or rollback the
+ caller's session (caller owns the commit).
+
+ ``admin_user`` is the org-space owner whose mPass ``access_token`` mints the
+ key and who owns the four config rows. ``cfg`` / ``access_token`` /
+ ``http_client`` semantics match :func:`ensure_personal_litellm_keys`.
+ """
+ if not should_auto_provision(cfg):
+ return False
+
+ user_id: uuid.UUID | None = None
+ search_space_id: int | None = None
+ askii_key_name: str | None = None
+
+ try:
+ user_id = admin_user.id
+ search_space_id = org_search_space.id
+
+ if org_search_space.litellm_auto_provisioned_at is not None:
+ return True
+
+ if access_token is None:
+ logger.warning(
+ "Org LiteLLM auto-provision skipped: missing mPass access token "
+ "for admin %s on org search_space %s",
+ user_id,
+ search_space_id,
+ )
+ return False
+
+ models = _resolve_models(cfg)
+ response = await _provision_via_askii(
+ access_token=access_token,
+ base_url=cfg.ASKII_BASE_URL,
+ duration_days=cfg.ASKII_LITELLM_KEY_DURATION_DAYS,
+ models=models,
+ user_id=user_id,
+ http_client=http_client,
+ )
+ if response is None:
+ return False
+
+ askii_key_name = response.key_name
+ api_key = response.api_key.get_secret_value()
+ api_base = cfg.ASKII_LITELLM_BASE_URL or cfg.ASKII_BASE_URL
+ rows = _build_config_rows(
+ user_id=user_id,
+ search_space_id=search_space_id,
+ models=models,
+ api_key=api_key,
+ api_base=api_base,
+ )
+
+ # SAVEPOINT isolates lock + writes from the caller's outer transaction
+ # (see ensure_personal_litellm_keys / module docstring).
+ try:
+ async with session.begin_nested():
+ await _check_space_marker_under_lock(session, search_space_id)
+ await _persist_and_wire_space(
+ session=session,
+ search_space=org_search_space,
+ search_space_id=search_space_id,
+ rows=rows,
+ )
+ except _RaceLossError:
+ logger.debug(
+ "Org LiteLLM auto-provision lost a race for org search_space %s — "
+ "upstream Askii key '%s' is orphaned (auto-expires)",
+ search_space_id,
+ askii_key_name,
+ )
+ return True
+
+ logger.info(
+ "Auto-provisioned org LiteLLM key '%s' (key_name=%s) and 4 config rows "
+ "on org search_space %s by admin %s",
+ LITELLM_KEY_ALIAS,
+ askii_key_name,
+ search_space_id,
+ user_id,
+ )
+ return True
+
+ except Exception:
+ logger.exception(
+ "Org LiteLLM auto-provision unexpectedly raised for admin %s on org "
+ "search_space %s — swallowed per best-effort contract; retry on next login",
+ user_id,
+ search_space_id,
+ )
+ return False
+
+
+async def ensure_org_litellm_keys_for_admin(
+ *,
+ session: AsyncSession,
+ user: User,
+ access_token: str | None,
+ cfg: Config,
+) -> bool:
+ """Provision the shared Organization space if ``user`` is its ``is_owner`` admin.
+
+ High-level entry point for :meth:`app.users.UserManager.on_after_login`.
+ Locates the shared SMB/Organization ``SearchSpace`` by name
+ (:func:`app.services.smb_auto_join.find_smb_search_space`), short-circuits
+ on its one-shot marker, gates on ``is_owner`` membership
+ (:func:`app.utils.rbac.is_search_space_owner`), then delegates to
+ :func:`ensure_org_litellm_keys`.
+
+ Best-effort: swallows every exception so callers can wire this into the
+ login hot path without guards. Returns ``False`` for any skipped/failed
+ outcome (gate off, no SMB space configured/created yet, caller is not the
+ org-space admin, transient upstream error) and ``True`` only when the org
+ space is confirmed provisioned.
+
+ ``cfg`` / ``access_token`` semantics: see
+ :func:`ensure_personal_litellm_keys`.
+ """
+ if not should_auto_provision(cfg):
+ return False
+
+ user_id: uuid.UUID | None = None
+ try:
+ user_id = user.id
+
+ org_search_space = await find_smb_search_space(session)
+ if org_search_space is None:
+ # No shared SMB/Organization space configured or created yet —
+ # nothing to provision. Retry on the admin's next login.
+ return False
+
+ if org_search_space.litellm_auto_provisioned_at is not None:
+ return True
+
+ if not await is_search_space_owner(session, user_id, org_search_space.id):
+ # Only the is_owner admin provisions the shared key. Any other
+ # member is a no-op (not an error).
+ return False
+
+ return await ensure_org_litellm_keys(
+ session=session,
+ admin_user=user,
+ org_search_space=org_search_space,
+ access_token=access_token,
+ cfg=cfg,
+ )
+ except Exception:
+ logger.exception(
+ "ensure_org_litellm_keys_for_admin: hook failed for user %s — "
+ "will retry on next login",
+ user_id,
+ )
+ return False
+
+
__all__ = [
"ALL_ROW_NAMES",
"LITELLM_KEY_ALIAS",
@@ -725,6 +978,8 @@ async def ensure_personal_litellm_keys_for_user(
"ROW_NAME_DOC_SUMMARY",
"ROW_NAME_IMAGE",
"ROW_NAME_VISION",
+ "ensure_org_litellm_keys",
+ "ensure_org_litellm_keys_for_admin",
"ensure_personal_litellm_keys",
"ensure_personal_litellm_keys_for_user",
"read_mpass_access_token",
diff --git a/surfsense_backend/app/services/smb_auto_join.py b/surfsense_backend/app/services/smb_auto_join.py
index a2ae182f5..6dc3da7e3 100644
--- a/surfsense_backend/app/services/smb_auto_join.py
+++ b/surfsense_backend/app/services/smb_auto_join.py
@@ -19,6 +19,44 @@
logger = logging.getLogger(__name__)
+def _smb_workspace_slug() -> str:
+ """The configured shared SMB/Organization workspace name, or "".
+
+ Priority: ``SMB_DEFAULT_WORKSPACE_NAME`` then ``SMB_NAME``. Stripped;
+ empty when neither is set (callers treat that as "no shared space").
+ """
+ slug = (
+ getattr(config, "SMB_DEFAULT_WORKSPACE_NAME", None)
+ or getattr(config, "SMB_NAME", "")
+ or ""
+ )
+ return slug.strip() if isinstance(slug, str) else ""
+
+
+async def find_smb_search_space(session) -> SearchSpace | None:
+ """Return the shared SMB/Organization ``SearchSpace`` by name, or None.
+
+ Matches the space whose ``name`` equals :func:`_smb_workspace_slug`,
+ skipping ``"[DELETING] "`` soft-delete tombstones and preferring the
+ oldest by id. Returns None when no name is configured or no space
+ matches. Uses the caller's ``session`` so it can run inside an existing
+ request-scoped transaction (e.g. the login hook) as well as the
+ standalone session opened by :func:`auto_join_smb_search_space`.
+ """
+ slug = _smb_workspace_slug()
+ if not slug:
+ return None
+
+ not_deleting = ~SearchSpace.name.startswith("[DELETING] ")
+ result = await session.execute(
+ select(SearchSpace)
+ .where(SearchSpace.name == slug, not_deleting)
+ .order_by(SearchSpace.id.asc())
+ .limit(1)
+ )
+ return result.scalars().first()
+
+
async def auto_join_smb_search_space(user_id: uuid.UUID) -> None:
"""
``_auto_join_workspace``: match the search space whose
@@ -28,25 +66,12 @@ async def auto_join_smb_search_space(user_id: uuid.UUID) -> None:
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()
+ smb_slug = _smb_workspace_slug()
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()
+ space = await find_smb_search_space(session)
if space is None:
logger.debug(
"SMB auto-join: no search space named %r — skipping",
diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py
index 9400cc4f3..0d64997c7 100644
--- a/surfsense_backend/app/users.py
+++ b/surfsense_backend/app/users.py
@@ -28,6 +28,7 @@
)
from app.prompts.system_defaults import SYSTEM_PROMPT_DEFAULTS
from app.services.litellm_provisioning import (
+ ensure_org_litellm_keys_for_admin,
ensure_personal_litellm_keys,
ensure_personal_litellm_keys_for_user,
read_mpass_access_token,
@@ -204,10 +205,11 @@ async def on_after_login(
return
if request is not None:
session = _session_from_user_db(self.user_db)
+ access_token = read_mpass_access_token(request)
await ensure_personal_litellm_keys_for_user(
session=session,
user=user,
- access_token=read_mpass_access_token(request),
+ access_token=access_token,
cfg=config,
)
# Commit any pending writes from the provisioning service
@@ -231,6 +233,33 @@ async def on_after_login(
user.id,
)
+ # Org-space LiteLLM provisioning (best-effort, separate from the
+ # personal path above). Fires only for the is_owner admin of the
+ # shared SMB/Organization space; one-shot per org space via
+ # SearchSpace.litellm_auto_provisioned_at. Committed separately so a
+ # personal-path commit failure cannot suppress the org write (and
+ # vice versa). Reuses the access token already read above.
+ await ensure_org_litellm_keys_for_admin(
+ session=session,
+ user=user,
+ access_token=access_token,
+ cfg=config,
+ )
+ try:
+ await session.commit()
+ except Exception:
+ logger.exception(
+ "on_after_login: post-org-provisioning commit failed for user %s",
+ user.id,
+ )
+ try:
+ await session.rollback()
+ except Exception:
+ logger.exception(
+ "on_after_login: rollback after failed org commit also failed for user %s",
+ user.id,
+ )
+
async def _update_last_login_throttled(self, user: User) -> None:
"""Update ``user.last_login`` at most once per throttle window.
diff --git a/surfsense_backend/tests/unit/test_litellm_provisioning.py b/surfsense_backend/tests/unit/test_litellm_provisioning.py
index a18c41036..21cb6057a 100644
--- a/surfsense_backend/tests/unit/test_litellm_provisioning.py
+++ b/surfsense_backend/tests/unit/test_litellm_provisioning.py
@@ -47,6 +47,8 @@
ROW_NAME_DOC_SUMMARY,
ROW_NAME_IMAGE,
ROW_NAME_VISION,
+ ensure_org_litellm_keys,
+ ensure_org_litellm_keys_for_admin,
ensure_personal_litellm_keys,
ensure_personal_litellm_keys_for_user,
read_mpass_access_token,
@@ -94,13 +96,21 @@ class _FakeSearchSpace:
we use a real class to make FK assertions meaningful.
"""
- def __init__(self, *, user_id: uuid.UUID, ss_id: int = 1) -> None:
+ def __init__(
+ self,
+ *,
+ user_id: uuid.UUID,
+ ss_id: int = 1,
+ provisioned_at: datetime | None = None,
+ ) -> None:
self.id = ss_id
self.user_id = user_id
self.agent_llm_id = 0
self.document_summary_llm_id = 0
self.image_generation_config_id = 0
self.vision_llm_config_id = 0
+ # Org-space one-shot marker (SearchSpace-scoped). NULL = eligible.
+ self.litellm_auto_provisioned_at = provisioned_at
def _make_session(*, lock_finds_marker: bool = False) -> AsyncMock:
@@ -1162,3 +1172,454 @@ def handler(req: httpx.Request) -> httpx.Response:
# caller's pending writes).
session.begin_nested.assert_not_called()
session.rollback.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# ensure_org_litellm_keys — shared Organization-space provisioning (core)
+# ---------------------------------------------------------------------------
+
+
+async def test_org_happy_path_inserts_four_rows_links_fks_and_stamps_space_marker(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Org analogue of the personal happy path: four rows are inserted and
+ the org SearchSpace's four FKs are wired, but the one-shot marker is
+ stamped on the SEARCH SPACE (not the admin user)."""
+ cfg = _set_config(
+ monkeypatch,
+ ASKII_BASE_URL="https://api.askii.test",
+ ASKII_LITELLM_BASE_URL="https://litellm.askii.test",
+ ASKII_AGENT_MODEL="gpt-5.4-mini",
+ ASKII_DOCUMENT_SUMMARY_MODEL="gpt-doc-summary",
+ ASKII_IMAGE_GEN_MODEL="gpt-image-1.5",
+ ASKII_VISION_MODEL="gpt-5.4-nano",
+ ASKII_LITELLM_KEY_DURATION_DAYS=30,
+ )
+ session = _make_session()
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(user_id=admin.id, ss_id=77)
+
+ before = datetime.now(UTC)
+ recorded: list[httpx.Request] = []
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ recorded.append(req)
+ return httpx.Response(
+ 200,
+ json={
+ "api_key": "sk-org-key-1234567890",
+ "key_name": "moneta-org-001",
+ "user_id": "askii-user-1",
+ "expires": "2026-08-01T00:00:00Z",
+ },
+ )
+
+ client = _mock_transport(handler)
+ try:
+ ok = await ensure_org_litellm_keys(
+ session=session,
+ admin_user=admin,
+ org_search_space=org_ss,
+ access_token="cognito-jwt",
+ http_client=client,
+ cfg=cfg,
+ )
+ finally:
+ await client.aclose()
+
+ assert ok is True
+
+ # exactly one Askii call with the admin's token + key alias
+ assert len(recorded) == 1
+ assert recorded[0].url.path == "/platform/provision-key"
+ sent = json.loads(recorded[0].content)
+ assert sent["mpass_token"] == "cognito-jwt"
+ assert sent["key_alias"] == LITELLM_KEY_ALIAS
+ assert sent["duration_days"] == 30
+ assert sent["default_model"] == "gpt-5.4-mini"
+
+ # four rows, all scoped to the ORG space and owned by the admin user
+ llm_rows = [o for o in session._added if isinstance(o, NewLLMConfig)]
+ image_rows = [o for o in session._added if isinstance(o, ImageGenerationConfig)]
+ vision_rows = [o for o in session._added if isinstance(o, VisionLLMConfig)]
+ assert len(llm_rows) == 2
+ assert len(image_rows) == 1
+ assert len(vision_rows) == 1
+ for row in session._added:
+ assert row.search_space_id == 77
+ assert row.user_id == admin.id
+ assert row.api_key == "sk-org-key-1234567890"
+
+ # all four FKs wired up on the org space
+ by_name = {r.name: r for r in llm_rows}
+ assert org_ss.agent_llm_id == by_name[ROW_NAME_AGENT].id
+ assert org_ss.document_summary_llm_id == by_name[ROW_NAME_DOC_SUMMARY].id
+ assert org_ss.image_generation_config_id == image_rows[0].id
+ assert org_ss.vision_llm_config_id == vision_rows[0].id
+
+ # one-shot marker stamped on the SEARCH SPACE …
+ assert org_ss.litellm_auto_provisioned_at is not None
+ assert before <= org_ss.litellm_auto_provisioned_at <= datetime.now(UTC)
+ # … and NOT on the admin user (org path is independent of the personal
+ # per-user marker).
+ assert admin.litellm_auto_provisioned_at is None
+
+ session.begin_nested.assert_called_once()
+ session.commit.assert_not_called()
+ session.rollback.assert_not_called()
+
+
+async def test_org_returns_false_when_feature_disabled(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ cfg = _set_config(monkeypatch, AUTO_PROVISION_LITELLM_KEY=False)
+ session = _make_session()
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(user_id=admin.id)
+
+ sdk_called = False
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ nonlocal sdk_called
+ sdk_called = True
+ return httpx.Response(200, json={})
+
+ client = _mock_transport(handler)
+ try:
+ ok = await ensure_org_litellm_keys(
+ session=session,
+ admin_user=admin,
+ org_search_space=org_ss,
+ access_token="jwt",
+ http_client=client,
+ cfg=cfg,
+ )
+ finally:
+ await client.aclose()
+
+ assert ok is False
+ assert sdk_called is False
+ session.add.assert_not_called()
+ assert org_ss.litellm_auto_provisioned_at is None
+
+
+async def test_org_skips_when_access_token_missing(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ cfg = _set_config(monkeypatch)
+ session = _make_session()
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(user_id=admin.id)
+
+ sdk_called = False
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ nonlocal sdk_called
+ sdk_called = True
+ return httpx.Response(200, json={})
+
+ client = _mock_transport(handler)
+ try:
+ ok = await ensure_org_litellm_keys(
+ session=session,
+ admin_user=admin,
+ org_search_space=org_ss,
+ access_token=None,
+ http_client=client,
+ cfg=cfg,
+ )
+ finally:
+ await client.aclose()
+
+ assert ok is False
+ assert sdk_called is False
+ session.add.assert_not_called()
+ assert org_ss.litellm_auto_provisioned_at is None
+
+
+async def test_org_idempotent_when_space_already_provisioned(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """SearchSpace.litellm_auto_provisioned_at is the org one-shot marker —
+ once set, short-circuit to True with no session I/O and no Askii call."""
+ cfg = _set_config(monkeypatch)
+ stamped = datetime(2026, 5, 1, 12, 0, 0, tzinfo=UTC)
+ session = _make_session()
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(user_id=admin.id, provisioned_at=stamped)
+
+ sdk_called = False
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ nonlocal sdk_called
+ sdk_called = True
+ return httpx.Response(200, json={})
+
+ client = _mock_transport(handler)
+ try:
+ ok = await ensure_org_litellm_keys(
+ session=session,
+ admin_user=admin,
+ org_search_space=org_ss,
+ access_token="jwt",
+ http_client=client,
+ cfg=cfg,
+ )
+ finally:
+ await client.aclose()
+
+ assert ok is True
+ assert sdk_called is False
+ session.add.assert_not_called()
+ session.execute.assert_not_called()
+ assert org_ss.litellm_auto_provisioned_at == stamped
+
+
+async def test_org_race_loss_inside_lock_returns_true_without_writes(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """In-lock SELECT FOR UPDATE finds the space marker already stamped (a
+ sibling admin won the race during our Askii call) → return True, write
+ no rows; the upstream key we minted is orphaned and self-expires."""
+ cfg = _set_config(monkeypatch)
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(user_id=admin.id, ss_id=21)
+ session = _make_session(lock_finds_marker=True)
+
+ sdk_called = False
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ nonlocal sdk_called
+ sdk_called = True
+ return httpx.Response(
+ 200,
+ json={
+ "api_key": "sk-org-wasted",
+ "key_name": "orphan-org-key",
+ "user_id": "u",
+ "expires": None,
+ },
+ )
+
+ client = _mock_transport(handler)
+ try:
+ ok = await ensure_org_litellm_keys(
+ session=session,
+ admin_user=admin,
+ org_search_space=org_ss,
+ access_token="jwt",
+ http_client=client,
+ cfg=cfg,
+ )
+ finally:
+ await client.aclose()
+
+ assert ok is True
+ assert sdk_called is True
+ session.add.assert_not_called()
+ session.commit.assert_not_called()
+ session.begin_nested.assert_called_once()
+ session.rollback.assert_not_called()
+ assert org_ss.agent_llm_id == 0
+ assert org_ss.litellm_auto_provisioned_at is None
+
+
+async def test_org_transient_500_returns_false_no_rows(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ cfg = _set_config(monkeypatch)
+ session = _make_session()
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(user_id=admin.id)
+
+ def handler(req: httpx.Request) -> httpx.Response:
+ return httpx.Response(500, json={"detail": "boom"})
+
+ client = _mock_transport(handler)
+ try:
+ ok = await ensure_org_litellm_keys(
+ session=session,
+ admin_user=admin,
+ org_search_space=org_ss,
+ access_token="jwt",
+ http_client=client,
+ cfg=cfg,
+ )
+ finally:
+ await client.aclose()
+
+ assert ok is False
+ session.add.assert_not_called()
+ assert org_ss.agent_llm_id == 0
+ assert org_ss.litellm_auto_provisioned_at is None
+ session.commit.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# ensure_org_litellm_keys_for_admin — wrapper (org-space discovery + is_owner)
+# ---------------------------------------------------------------------------
+
+
+async def test_org_wrapper_returns_false_when_feature_disabled(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Gate is checked first — no org-space lookup, no delegation."""
+ find_smb = AsyncMock(return_value=None)
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.find_smb_search_space", find_smb
+ )
+
+ ok = await ensure_org_litellm_keys_for_admin(
+ session=AsyncMock(),
+ user=_FakeUser(),
+ access_token="jwt",
+ cfg=_cfg_ns(AUTO_PROVISION_LITELLM_KEY=False),
+ )
+
+ assert ok is False
+ find_smb.assert_not_awaited()
+
+
+async def test_org_wrapper_returns_false_when_no_org_space(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """No shared SMB/Organization space found → False (retry next login)."""
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.find_smb_search_space",
+ AsyncMock(return_value=None),
+ )
+ delegate = AsyncMock()
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.ensure_org_litellm_keys", delegate
+ )
+
+ ok = await ensure_org_litellm_keys_for_admin(
+ session=AsyncMock(),
+ user=_FakeUser(),
+ access_token="jwt",
+ cfg=_cfg_ns(),
+ )
+
+ assert ok is False
+ delegate.assert_not_awaited()
+
+
+async def test_org_wrapper_returns_true_when_space_already_provisioned(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Org space marker already set → True; no ownership check, no delegation."""
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(
+ user_id=admin.id, provisioned_at=datetime(2026, 5, 1, tzinfo=UTC)
+ )
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.find_smb_search_space",
+ AsyncMock(return_value=org_ss),
+ )
+ is_owner = AsyncMock(return_value=True)
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.is_search_space_owner", is_owner
+ )
+ delegate = AsyncMock()
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.ensure_org_litellm_keys", delegate
+ )
+
+ ok = await ensure_org_litellm_keys_for_admin(
+ session=AsyncMock(),
+ user=admin,
+ access_token="jwt",
+ cfg=_cfg_ns(),
+ )
+
+ assert ok is True
+ is_owner.assert_not_awaited()
+ delegate.assert_not_awaited()
+
+
+async def test_org_wrapper_returns_false_when_user_not_owner(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Caller is a member but not the is_owner admin → no-op (False)."""
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(user_id=admin.id)
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.find_smb_search_space",
+ AsyncMock(return_value=org_ss),
+ )
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.is_search_space_owner",
+ AsyncMock(return_value=False),
+ )
+ delegate = AsyncMock()
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.ensure_org_litellm_keys", delegate
+ )
+
+ ok = await ensure_org_litellm_keys_for_admin(
+ session=AsyncMock(),
+ user=admin,
+ access_token="jwt",
+ cfg=_cfg_ns(),
+ )
+
+ assert ok is False
+ delegate.assert_not_awaited()
+
+
+async def test_org_wrapper_delegates_when_owner(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Org space found, marker NULL, caller is is_owner → delegate to
+ ensure_org_litellm_keys with the discovered space + admin user."""
+ admin = _FakeUser()
+ org_ss = _FakeSearchSpace(user_id=admin.id, ss_id=88)
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.find_smb_search_space",
+ AsyncMock(return_value=org_ss),
+ )
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.is_search_space_owner",
+ AsyncMock(return_value=True),
+ )
+ delegate = AsyncMock(return_value=True)
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.ensure_org_litellm_keys", delegate
+ )
+
+ session = AsyncMock()
+ cfg = _cfg_ns()
+ ok = await ensure_org_litellm_keys_for_admin(
+ session=session,
+ user=admin,
+ access_token="jwt",
+ cfg=cfg,
+ )
+
+ assert ok is True
+ delegate.assert_awaited_once()
+ kwargs = delegate.await_args.kwargs
+ assert kwargs["admin_user"] is admin
+ assert kwargs["org_search_space"] is org_ss
+ assert kwargs["access_token"] == "jwt"
+ assert kwargs["session"] is session
+ assert kwargs["cfg"] is cfg
+
+
+async def test_org_wrapper_swallows_unexpected_exception(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Best-effort: any error during org-space lookup is logged + swallowed."""
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.find_smb_search_space",
+ AsyncMock(side_effect=RuntimeError("db down")),
+ )
+
+ ok = await ensure_org_litellm_keys_for_admin(
+ session=AsyncMock(),
+ user=_FakeUser(),
+ access_token="jwt",
+ cfg=_cfg_ns(),
+ )
+
+ assert ok is False
diff --git a/surfsense_backend/tests/unit/test_user_manager_on_after_login.py b/surfsense_backend/tests/unit/test_user_manager_on_after_login.py
index dd4579a3d..4d36548fd 100644
--- a/surfsense_backend/tests/unit/test_user_manager_on_after_login.py
+++ b/surfsense_backend/tests/unit/test_user_manager_on_after_login.py
@@ -135,7 +135,10 @@ async def test_on_after_login_commits_session_after_provisioning() -> None:
the wrapper returns. Otherwise the SAVEPOINT-released writes would
be rolled back when the session closes.
- Empty commit on the marker fast path is acceptable (cheap no-op).
+ The hook runs TWO provisioning steps — personal then org-space — each
+ with its own commit so a failure in one cannot suppress the other.
+ Empty commits on the marker / gate fast paths are acceptable (cheap
+ no-ops).
"""
user = _FakeUser(is_active=True)
request = _make_request()
@@ -147,6 +150,10 @@ async def test_on_after_login_commits_session_after_provisioning() -> None:
"app.users.ensure_personal_litellm_keys_for_user",
new=AsyncMock(),
),
+ patch(
+ "app.users.ensure_org_litellm_keys_for_admin",
+ new=AsyncMock(),
+ ),
patch.object(
manager,
"_update_last_login_throttled",
@@ -155,12 +162,17 @@ async def test_on_after_login_commits_session_after_provisioning() -> None:
):
await manager.on_after_login(user, request=request)
- user_db.session.commit.assert_awaited_once()
+ # One commit after the personal step, one after the org step.
+ assert user_db.session.commit.await_count == 2
async def test_on_after_login_swallows_commit_failure() -> None:
"""Commit failure after provisioning must NOT propagate — login flows
- must never break on bookkeeping. Logged + swallowed."""
+ must never break on bookkeeping. Logged + swallowed.
+
+ Both commit boundaries (personal + org-space) are attempted; a failure
+ in either is swallowed independently.
+ """
user = _FakeUser(is_active=True)
request = _make_request()
user_db = _make_user_db_with_session()
@@ -172,6 +184,10 @@ async def test_on_after_login_swallows_commit_failure() -> None:
"app.users.ensure_personal_litellm_keys_for_user",
new=AsyncMock(),
),
+ patch(
+ "app.users.ensure_org_litellm_keys_for_admin",
+ new=AsyncMock(),
+ ),
patch.object(
manager,
"_update_last_login_throttled",
@@ -181,7 +197,47 @@ async def test_on_after_login_swallows_commit_failure() -> None:
# Must not raise.
await manager.on_after_login(user, request=request)
- user_db.session.commit.assert_awaited_once()
+ # Both commits attempted; both failures swallowed.
+ assert user_db.session.commit.await_count == 2
+
+
+async def test_on_after_login_fires_org_provisioning_when_user_active() -> None:
+ """Active user with a request triggers org-space provisioning too, with
+ the same session + access token as the personal step (token read once)."""
+ user = _FakeUser(is_active=True)
+ request = _make_request()
+ user_db = _make_user_db_with_session()
+ manager = UserManager(user_db)
+
+ with (
+ patch(
+ "app.users.ensure_personal_litellm_keys_for_user",
+ new=AsyncMock(),
+ ) as personal_wrapper,
+ patch(
+ "app.users.ensure_org_litellm_keys_for_admin",
+ new=AsyncMock(),
+ ) as org_wrapper,
+ patch.object(
+ manager,
+ "_update_last_login_throttled",
+ new=AsyncMock(),
+ ),
+ ):
+ await manager.on_after_login(user, request=request)
+
+ from app.users import config as app_cfg
+
+ assert org_wrapper.await_count == 1
+ org_kwargs = org_wrapper.await_args.kwargs
+ assert org_kwargs["session"] is user_db.session
+ assert org_kwargs["user"] is user
+ assert org_kwargs["cfg"] is app_cfg
+ # The org step reuses the exact access token read once for the personal
+ # step (single read_mpass_access_token call shared across both).
+ assert (
+ org_kwargs["access_token"] == personal_wrapper.await_args.kwargs["access_token"]
+ )
async def test_on_after_login_skips_provisioning_when_request_is_none() -> None:
From 32f1eebd26a57153cb6bf2f7af199ff8be2f3480 Mon Sep 17 00:00:00 2001
From: _ <50262751+hunzlahmalik@users.noreply.github.com>
Date: Wed, 3 Jun 2026 14:47:44 +0500
Subject: [PATCH 02/11] perf(surfsense): skip org-space DB lookup when mPass
token absent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address Copilot review on #29: ensure_org_litellm_keys_for_admin did the SMB
space lookup + ownership query before discovering it had no mPass access token
to mint with. Return early on a None token (after the feature gate, before any
DB round-trip) — provisioning can't succeed without it anyway, and non-mPass
logins (fastapi-users JWT, Google OAuth, dev hitting the backend directly) hit
this every request. The core ensure_org_litellm_keys keeps its own None-token
check as defense-in-depth; the marker stays NULL so the admin's next mPass
login retries. Adds a wrapper test asserting no DB calls when the token is None.
Co-Authored-By: Claude Opus 4.8
---
.../app/services/litellm_provisioning.py | 15 ++++++++---
.../tests/unit/test_litellm_provisioning.py | 26 +++++++++++++++++++
2 files changed, 38 insertions(+), 3 deletions(-)
diff --git a/surfsense_backend/app/services/litellm_provisioning.py b/surfsense_backend/app/services/litellm_provisioning.py
index 6bd33bdf0..e06769fde 100644
--- a/surfsense_backend/app/services/litellm_provisioning.py
+++ b/surfsense_backend/app/services/litellm_provisioning.py
@@ -927,9 +927,9 @@ async def ensure_org_litellm_keys_for_admin(
Best-effort: swallows every exception so callers can wire this into the
login hot path without guards. Returns ``False`` for any skipped/failed
- outcome (gate off, no SMB space configured/created yet, caller is not the
- org-space admin, transient upstream error) and ``True`` only when the org
- space is confirmed provisioned.
+ outcome (gate off, no mPass access token, no SMB space configured/created
+ yet, caller is not the org-space admin, transient upstream error) and
+ ``True`` only when the org space is confirmed provisioned.
``cfg`` / ``access_token`` semantics: see
:func:`ensure_personal_litellm_keys`.
@@ -937,6 +937,15 @@ async def ensure_org_litellm_keys_for_admin(
if not should_auto_provision(cfg):
return False
+ # Provisioning mints an Askii key from the mPass Cognito access token —
+ # skip the SMB-space lookup + ownership query entirely when there is no
+ # token (non-mPass logins: fastapi-users JWT, Google OAuth, dev hitting the
+ # backend directly). The core ensure_org_litellm_keys re-checks this as
+ # defense-in-depth; here it just avoids the two DB round-trips. The marker
+ # stays NULL, so the admin's next mPass login retries.
+ if access_token is None:
+ return False
+
user_id: uuid.UUID | None = None
try:
user_id = user.id
diff --git a/surfsense_backend/tests/unit/test_litellm_provisioning.py b/surfsense_backend/tests/unit/test_litellm_provisioning.py
index 21cb6057a..06cb71ad9 100644
--- a/surfsense_backend/tests/unit/test_litellm_provisioning.py
+++ b/surfsense_backend/tests/unit/test_litellm_provisioning.py
@@ -1480,6 +1480,32 @@ async def test_org_wrapper_returns_false_when_feature_disabled(
find_smb.assert_not_awaited()
+async def test_org_wrapper_skips_db_when_token_missing(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """No mPass token → return False before any DB work (can't mint a key
+ without it). Covers non-mPass logins (JWT/OAuth, dev-direct)."""
+ find_smb = AsyncMock(return_value=None)
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.find_smb_search_space", find_smb
+ )
+ is_owner = AsyncMock(return_value=True)
+ monkeypatch.setattr(
+ "app.services.litellm_provisioning.is_search_space_owner", is_owner
+ )
+
+ ok = await ensure_org_litellm_keys_for_admin(
+ session=AsyncMock(),
+ user=_FakeUser(),
+ access_token=None,
+ cfg=_cfg_ns(),
+ )
+
+ assert ok is False
+ find_smb.assert_not_awaited()
+ is_owner.assert_not_awaited()
+
+
async def test_org_wrapper_returns_false_when_no_org_space(
monkeypatch: pytest.MonkeyPatch,
) -> None:
From df08e71067bb93220b3d634d9ae8ca4d92881de3 Mon Sep 17 00:00:00 2001
From: Ahtisahm Shahid
Date: Wed, 3 Jun 2026 15:11:14 +0500
Subject: [PATCH 03/11] fix(backend): import SECRET from app.users in app.py
Resolves F821 ruff errors on lines 883, 893, 942 where SECRET was used
but never imported into app.py (it lives in app.users).
---
surfsense_backend/app/app.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py
index 5130aa47e..c07488806 100644
--- a/surfsense_backend/app/app.py
+++ b/surfsense_backend/app/app.py
@@ -43,6 +43,7 @@
from app.schemas import UserCreate, UserRead, UserUpdate
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
from app.users import (
+ SECRET,
auth_backend,
current_active_user,
fastapi_users,
From 6aa66643c0f8f5226955a1e0cf0e85043ec739fc Mon Sep 17 00:00:00 2001
From: Azan Ali
Date: Wed, 3 Jun 2026 15:13:58 +0500
Subject: [PATCH 04/11] feat(web): hide download UI when SSO auth is active
Suppress the DownloadButton on the hero section and the download CTA
in the sidebar user profile when isSSOAuth() is true, so managed/SSO
deployments don't surface desktop-download prompts.
Co-Authored-By: Claude Sonnet 4.6
---
surfsense_web/components/homepage/hero-section.tsx | 2 +-
.../components/layout/ui/sidebar/SidebarUserProfile.tsx | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx
index d2bbd3079..705bea6fb 100644
--- a/surfsense_web/components/homepage/hero-section.tsx
+++ b/surfsense_web/components/homepage/hero-section.tsx
@@ -164,7 +164,7 @@ export function HeroSection() {
-
+ {!isSSOAuth() && }
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
index bc3b36efd..fddd9e8df 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
@@ -36,7 +36,7 @@ import { useLocaleContext } from "@/contexts/LocaleContext";
import { useMediaQuery } from "@/hooks/use-media-query";
import { usePlatform } from "@/hooks/use-platform";
import { GITHUB_RELEASES_URL, usePrimaryDownload } from "@/lib/desktop-download-utils";
-import { APP_VERSION } from "@/lib/env-config";
+import { APP_VERSION, isSSOAuth } from "@/lib/env-config";
import { trackDesktopDownloadClicked } from "@/lib/posthog/events";
import { getUserAvatarColor, getUserInitials } from "@/lib/user-avatar";
import { cn } from "@/lib/utils";
@@ -146,7 +146,7 @@ export function SidebarUserProfile({
const displayName = user.name || user.email.split("@")[0];
const downloadUrl = primary?.url ?? GITHUB_RELEASES_URL;
const downloadLabel = t("download_for_os", { os });
- const showDownloadCta = !isDesktop && isDesktopViewport;
+ const showDownloadCta = !isDesktop && isDesktopViewport && !isSSOAuth();
const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => {
setLocale(newLocale);
From f97b39272c4d032cd29044e56e7c924747af80f2 Mon Sep 17 00:00:00 2001
From: _ <50262751+hunzlahmalik@users.noreply.github.com>
Date: Wed, 3 Jun 2026 15:17:56 +0500
Subject: [PATCH 05/11] perf(surfsense): skip org provisioning step + commit on
non-mPass logins
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address Copilot review on #29: on_after_login ran the org provisioning call
and a second COMMIT even when access_token was None. The org wrapper now
early-returns without DB work in that case, so the commit was an empty no-op on
a hook that fires on every authenticated request. Guard the org call + commit
on `access_token is not None` (fastapi-users JWT, Google OAuth, dev-direct
flows reach this hook without an mPass token). Behavior-preserving — org
provisioning cannot run without the token anyway. Adds a hook test asserting
the org step is skipped and only the personal step commits when no token.
Co-Authored-By: Claude Opus 4.8
---
surfsense_backend/app/users.py | 35 +++++++++++--------
.../unit/test_user_manager_on_after_login.py | 35 +++++++++++++++++++
2 files changed, 56 insertions(+), 14 deletions(-)
diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py
index 0d64997c7..dd58f379c 100644
--- a/surfsense_backend/app/users.py
+++ b/surfsense_backend/app/users.py
@@ -239,26 +239,33 @@ async def on_after_login(
# SearchSpace.litellm_auto_provisioned_at. Committed separately so a
# personal-path commit failure cannot suppress the org write (and
# vice versa). Reuses the access token already read above.
- await ensure_org_litellm_keys_for_admin(
- session=session,
- user=user,
- access_token=access_token,
- cfg=config,
- )
- try:
- await session.commit()
- except Exception:
- logger.exception(
- "on_after_login: post-org-provisioning commit failed for user %s",
- user.id,
+ #
+ # Guarded on a present mPass token: org provisioning mints an Askii
+ # key from it and is a no-op without it (the wrapper early-returns),
+ # so skip the call AND its otherwise-empty commit on non-mPass
+ # logins (fastapi-users JWT, Google OAuth, dev-direct) that still
+ # reach this hook on every authenticated request.
+ if access_token is not None:
+ await ensure_org_litellm_keys_for_admin(
+ session=session,
+ user=user,
+ access_token=access_token,
+ cfg=config,
)
try:
- await session.rollback()
+ await session.commit()
except Exception:
logger.exception(
- "on_after_login: rollback after failed org commit also failed for user %s",
+ "on_after_login: post-org-provisioning commit failed for user %s",
user.id,
)
+ try:
+ await session.rollback()
+ except Exception:
+ logger.exception(
+ "on_after_login: rollback after failed org commit also failed for user %s",
+ user.id,
+ )
async def _update_last_login_throttled(self, user: User) -> None:
"""Update ``user.last_login`` at most once per throttle window.
diff --git a/surfsense_backend/tests/unit/test_user_manager_on_after_login.py b/surfsense_backend/tests/unit/test_user_manager_on_after_login.py
index 4d36548fd..16195a247 100644
--- a/surfsense_backend/tests/unit/test_user_manager_on_after_login.py
+++ b/surfsense_backend/tests/unit/test_user_manager_on_after_login.py
@@ -240,6 +240,41 @@ async def test_on_after_login_fires_org_provisioning_when_user_active() -> None:
)
+async def test_on_after_login_skips_org_step_when_no_mpass_token() -> None:
+ """Non-mPass login (no access token) → the org provisioning step and its
+ commit are skipped entirely; only the personal step commits.
+
+ Org provisioning mints an Askii key from the mPass token and cannot run
+ without it — guarding the call avoids an otherwise-empty commit on a hook
+ that fires on every authenticated request (JWT/OAuth/dev-direct flows)."""
+ user = _FakeUser(is_active=True)
+ request = _make_request()
+ user_db = _make_user_db_with_session()
+ manager = UserManager(user_db)
+
+ with (
+ patch("app.users.read_mpass_access_token", return_value=None),
+ patch(
+ "app.users.ensure_personal_litellm_keys_for_user",
+ new=AsyncMock(),
+ ),
+ patch(
+ "app.users.ensure_org_litellm_keys_for_admin",
+ new=AsyncMock(),
+ ) as org_wrapper,
+ patch.object(
+ manager,
+ "_update_last_login_throttled",
+ new=AsyncMock(),
+ ),
+ ):
+ await manager.on_after_login(user, request=request)
+
+ org_wrapper.assert_not_called()
+ # Org step (and its commit) skipped — only the personal commit ran.
+ assert user_db.session.commit.await_count == 1
+
+
async def test_on_after_login_skips_provisioning_when_request_is_none() -> None:
"""fastapi-users flows that omit ``request`` (rare but legal per the
superclass signature) must not raise — the wrapper needs a request to
From 24380f3040e67f0b8748e00ef7c026a1b78065f6 Mon Sep 17 00:00:00 2001
From: Ahtisahm Shahid
Date: Wed, 3 Jun 2026 15:49:27 +0500
Subject: [PATCH 06/11] fix(ci): apply ruff-format and biome auto-fixes to
clear quality gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ruff-format: reformat surfsense_backend/app/app.py (trailing newline
after the new SECRET import)
- biome check --write: fix import ordering and formatting violations
across 28 files in surfsense_web/ (atoms, components/ui, components/
tool-ui, lib/apis, svgr.d.ts) — these pre-existing issues caused the
TypeScript/JavaScript Quality check to fail on every PR
---
surfsense_backend/app/app.py | 3 +++
surfsense_web/atoms/user/user-query.atoms.ts | 2 +-
surfsense_web/components/TokenHandler.tsx | 1 -
.../components/tool-ui/generate-image.tsx | 4 ++--
.../components/tool-ui/sandbox-execute.tsx | 2 +-
surfsense_web/components/tool-ui/user-memory.tsx | 4 ++--
surfsense_web/components/tool-ui/write-todos.tsx | 2 +-
surfsense_web/components/ui/accordion.tsx | 2 +-
surfsense_web/components/ui/alert-dialog.tsx | 14 +++++++-------
surfsense_web/components/ui/alert.tsx | 2 +-
surfsense_web/components/ui/animated-tabs.tsx | 2 +-
surfsense_web/components/ui/avatar.tsx | 2 +-
surfsense_web/components/ui/card.tsx | 2 +-
surfsense_web/components/ui/collapsible.tsx | 2 +-
surfsense_web/components/ui/command.tsx | 6 +++---
surfsense_web/components/ui/context-menu.tsx | 12 ++++++------
surfsense_web/components/ui/dialog.tsx | 10 +++++-----
surfsense_web/components/ui/drawer.tsx | 12 ++++++------
surfsense_web/components/ui/dropdown-menu.tsx | 10 +++++-----
.../components/ui/expanded-gif-overlay.tsx | 2 +-
surfsense_web/components/ui/form.tsx | 8 ++++----
surfsense_web/components/ui/pagination.tsx | 6 +++---
surfsense_web/components/ui/popover.tsx | 2 +-
surfsense_web/components/ui/sheet.tsx | 6 +++---
surfsense_web/components/ui/table.tsx | 2 +-
surfsense_web/components/ui/tabs.tsx | 2 +-
surfsense_web/components/ui/tooltip.tsx | 2 +-
surfsense_web/lib/apis/connectors-api.service.ts | 2 +-
surfsense_web/svgr.d.ts | 1 +
29 files changed, 65 insertions(+), 62 deletions(-)
diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py
index c07488806..8a8c8ee8d 100644
--- a/surfsense_backend/app/app.py
+++ b/surfsense_backend/app/app.py
@@ -820,6 +820,7 @@ async def dispatch(
tags=["auth"],
)
+
# Register /users/me BEFORE fastapi_users.get_users_router so our routes take
# precedence (FastAPI first-match wins). fastapi-users' internal /users/me only
# validates JWT — it does not check request.state.proxy_user set by the proxy
@@ -843,6 +844,8 @@ async def update_current_user_me(
# JWT-loaded one. user_manager.get() returns the session-attached instance.
db_user = await user_manager.get(user.id)
return await user_manager.update(user_update, db_user, safe=True, request=request)
+
+
app.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts
index 8792c4a96..4b6717440 100644
--- a/surfsense_web/atoms/user/user-query.atoms.ts
+++ b/surfsense_web/atoms/user/user-query.atoms.ts
@@ -1,6 +1,6 @@
import { atomWithQuery } from "jotai-tanstack-query";
-import { getBearerToken } from "@/lib/auth-utils";
import { userApiService } from "@/lib/apis/user-api.service";
+import { getBearerToken } from "@/lib/auth-utils";
export const USER_QUERY_KEY = ["user", "me"] as const;
const userQueryFn = () => userApiService.getMe();
diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx
index 88dd66550..4d160aa40 100644
--- a/surfsense_web/components/TokenHandler.tsx
+++ b/surfsense_web/components/TokenHandler.tsx
@@ -6,7 +6,6 @@
// To restore native OAuth login, uncomment this file and re-register
// the /auth/callback route.
-
// "use client";
//
// import { useEffect } from "react";
diff --git a/surfsense_web/components/tool-ui/generate-image.tsx b/surfsense_web/components/tool-ui/generate-image.tsx
index f077dcdad..7fab2bf40 100644
--- a/surfsense_web/components/tool-ui/generate-image.tsx
+++ b/surfsense_web/components/tool-ui/generate-image.tsx
@@ -135,8 +135,8 @@ export const GenerateImageToolUI = ({
};
export {
- GenerateImageArgsSchema,
- GenerateImageResultSchema,
type GenerateImageArgs,
+ GenerateImageArgsSchema,
type GenerateImageResult,
+ GenerateImageResultSchema,
};
diff --git a/surfsense_web/components/tool-ui/sandbox-execute.tsx b/surfsense_web/components/tool-ui/sandbox-execute.tsx
index 3d309332e..bf6595d24 100644
--- a/surfsense_web/components/tool-ui/sandbox-execute.tsx
+++ b/surfsense_web/components/tool-ui/sandbox-execute.tsx
@@ -419,4 +419,4 @@ export const SandboxExecuteToolUI = ({
return ;
};
-export { ExecuteArgsSchema, ExecuteResultSchema, type ExecuteArgs, type ExecuteResult };
+export { type ExecuteArgs, ExecuteArgsSchema, type ExecuteResult, ExecuteResultSchema };
diff --git a/surfsense_web/components/tool-ui/user-memory.tsx b/surfsense_web/components/tool-ui/user-memory.tsx
index f7c446806..0c8a0030d 100644
--- a/surfsense_web/components/tool-ui/user-memory.tsx
+++ b/surfsense_web/components/tool-ui/user-memory.tsx
@@ -90,8 +90,8 @@ export const UpdateMemoryToolUI = ({
// ============================================================================
export {
- UpdateMemoryArgsSchema,
- UpdateMemoryResultSchema,
type UpdateMemoryArgs,
+ UpdateMemoryArgsSchema,
type UpdateMemoryResult,
+ UpdateMemoryResultSchema,
};
diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx
index 104cbcf44..53c9083d8 100644
--- a/surfsense_web/components/tool-ui/write-todos.tsx
+++ b/surfsense_web/components/tool-ui/write-todos.tsx
@@ -157,4 +157,4 @@ export const WriteTodosToolUI = ({
);
};
-export { WriteTodosSchema, type WriteTodosData };
+export { type WriteTodosData, WriteTodosSchema };
diff --git a/surfsense_web/components/ui/accordion.tsx b/surfsense_web/components/ui/accordion.tsx
index 51ac3e849..e4c64ef83 100644
--- a/surfsense_web/components/ui/accordion.tsx
+++ b/surfsense_web/components/ui/accordion.tsx
@@ -61,4 +61,4 @@ function AccordionContent({
);
}
-export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
diff --git a/surfsense_web/components/ui/alert-dialog.tsx b/surfsense_web/components/ui/alert-dialog.tsx
index 4db90020f..f3ffc88ef 100644
--- a/surfsense_web/components/ui/alert-dialog.tsx
+++ b/surfsense_web/components/ui/alert-dialog.tsx
@@ -121,14 +121,14 @@ function AlertDialogCancel({
export {
AlertDialog,
- AlertDialogPortal,
- AlertDialogOverlay,
- AlertDialogTrigger,
+ AlertDialogAction,
+ AlertDialogCancel,
AlertDialogContent,
- AlertDialogHeader,
+ AlertDialogDescription,
AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+ AlertDialogPortal,
AlertDialogTitle,
- AlertDialogDescription,
- AlertDialogAction,
- AlertDialogCancel,
+ AlertDialogTrigger,
};
diff --git a/surfsense_web/components/ui/alert.tsx b/surfsense_web/components/ui/alert.tsx
index 2d777db67..39123888e 100644
--- a/surfsense_web/components/ui/alert.tsx
+++ b/surfsense_web/components/ui/alert.tsx
@@ -59,4 +59,4 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">)
);
}
-export { Alert, AlertTitle, AlertDescription };
+export { Alert, AlertDescription, AlertTitle };
diff --git a/surfsense_web/components/ui/animated-tabs.tsx b/surfsense_web/components/ui/animated-tabs.tsx
index 7bc12821f..333c6d8ce 100644
--- a/surfsense_web/components/ui/animated-tabs.tsx
+++ b/surfsense_web/components/ui/animated-tabs.tsx
@@ -556,4 +556,4 @@ const TabsContent = forwardRef<
});
TabsContent.displayName = "TabsContent";
-export { Tabs, TabsList, TabsTrigger, TabsContent };
+export { Tabs, TabsContent, TabsList, TabsTrigger };
diff --git a/surfsense_web/components/ui/avatar.tsx b/surfsense_web/components/ui/avatar.tsx
index 984a66e8c..e0314c53d 100644
--- a/surfsense_web/components/ui/avatar.tsx
+++ b/surfsense_web/components/ui/avatar.tsx
@@ -55,4 +55,4 @@ function AvatarGroupCount({ className, ...props }: React.ComponentProps<"span">)
);
}
-export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount };
+export { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
diff --git a/surfsense_web/components/ui/card.tsx b/surfsense_web/components/ui/card.tsx
index babb851b0..408aa5552 100644
--- a/surfsense_web/components/ui/card.tsx
+++ b/surfsense_web/components/ui/card.tsx
@@ -52,4 +52,4 @@ const CardFooter = React.forwardRef;
}
-export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+export { Collapsible, CollapsibleContent, CollapsibleTrigger };
diff --git a/surfsense_web/components/ui/command.tsx b/surfsense_web/components/ui/command.tsx
index 034dbab5e..9cf82113b 100644
--- a/surfsense_web/components/ui/command.tsx
+++ b/surfsense_web/components/ui/command.tsx
@@ -150,11 +150,11 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
export {
Command,
CommandDialog,
- CommandInput,
- CommandList,
CommandEmpty,
CommandGroup,
+ CommandInput,
CommandItem,
- CommandShortcut,
+ CommandList,
CommandSeparator,
+ CommandShortcut,
};
diff --git a/surfsense_web/components/ui/context-menu.tsx b/surfsense_web/components/ui/context-menu.tsx
index 8fa7c6d1a..f24e547f5 100644
--- a/surfsense_web/components/ui/context-menu.tsx
+++ b/surfsense_web/components/ui/context-menu.tsx
@@ -207,18 +207,18 @@ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span
export {
ContextMenu,
- ContextMenuTrigger,
+ ContextMenuCheckboxItem,
ContextMenuContent,
+ ContextMenuGroup,
ContextMenuItem,
- ContextMenuCheckboxItem,
- ContextMenuRadioItem,
ContextMenuLabel,
+ ContextMenuPortal,
+ ContextMenuRadioGroup,
+ ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
- ContextMenuGroup,
- ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
- ContextMenuRadioGroup,
+ ContextMenuTrigger,
};
diff --git a/surfsense_web/components/ui/dialog.tsx b/surfsense_web/components/ui/dialog.tsx
index 90c8bf538..a897379c3 100644
--- a/surfsense_web/components/ui/dialog.tsx
+++ b/surfsense_web/components/ui/dialog.tsx
@@ -92,13 +92,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
- DialogPortal,
- DialogOverlay,
DialogClose,
- DialogTrigger,
DialogContent,
- DialogHeader,
+ DialogDescription,
DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
DialogTitle,
- DialogDescription,
+ DialogTrigger,
};
diff --git a/surfsense_web/components/ui/drawer.tsx b/surfsense_web/components/ui/drawer.tsx
index d02996d96..cbb5a471b 100644
--- a/surfsense_web/components/ui/drawer.tsx
+++ b/surfsense_web/components/ui/drawer.tsx
@@ -102,14 +102,14 @@ DrawerHandle.displayName = "DrawerHandle";
export {
Drawer,
- DrawerPortal,
- DrawerOverlay,
- DrawerTrigger,
DrawerClose,
DrawerContent,
- DrawerHeader,
- DrawerFooter,
- DrawerTitle,
DrawerDescription,
+ DrawerFooter,
DrawerHandle,
+ DrawerHeader,
+ DrawerOverlay,
+ DrawerPortal,
+ DrawerTitle,
+ DrawerTrigger,
};
diff --git a/surfsense_web/components/ui/dropdown-menu.tsx b/surfsense_web/components/ui/dropdown-menu.tsx
index 421bcfcd2..666cda0fc 100644
--- a/surfsense_web/components/ui/dropdown-menu.tsx
+++ b/surfsense_web/components/ui/dropdown-menu.tsx
@@ -211,18 +211,18 @@ function DropdownMenuSubContent({
export {
DropdownMenu,
- DropdownMenuPortal,
- DropdownMenuTrigger,
+ DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
- DropdownMenuLabel,
DropdownMenuItem,
- DropdownMenuCheckboxItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
- DropdownMenuSubTrigger,
DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
};
diff --git a/surfsense_web/components/ui/expanded-gif-overlay.tsx b/surfsense_web/components/ui/expanded-gif-overlay.tsx
index 532ba1d32..33b40facf 100644
--- a/surfsense_web/components/ui/expanded-gif-overlay.tsx
+++ b/surfsense_web/components/ui/expanded-gif-overlay.tsx
@@ -91,4 +91,4 @@ const ExpandedGifOverlay = ExpandedMediaOverlay;
/** @deprecated Use useExpandedMedia instead */
const useExpandedGif = useExpandedMedia;
-export { ExpandedMediaOverlay, useExpandedMedia, ExpandedGifOverlay, useExpandedGif };
+export { ExpandedGifOverlay, ExpandedMediaOverlay, useExpandedGif, useExpandedMedia };
diff --git a/surfsense_web/components/ui/form.tsx b/surfsense_web/components/ui/form.tsx
index 55f003445..2093a523c 100644
--- a/surfsense_web/components/ui/form.tsx
+++ b/surfsense_web/components/ui/form.tsx
@@ -140,12 +140,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
}
export {
- useFormField,
Form,
- FormItem,
- FormLabel,
FormControl,
FormDescription,
- FormMessage,
FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ useFormField,
};
diff --git a/surfsense_web/components/ui/pagination.tsx b/surfsense_web/components/ui/pagination.tsx
index d8cb16cc1..613992d37 100644
--- a/surfsense_web/components/ui/pagination.tsx
+++ b/surfsense_web/components/ui/pagination.tsx
@@ -96,9 +96,9 @@ function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span"
export {
Pagination,
PaginationContent,
- PaginationLink,
+ PaginationEllipsis,
PaginationItem,
- PaginationPrevious,
+ PaginationLink,
PaginationNext,
- PaginationEllipsis,
+ PaginationPrevious,
};
diff --git a/surfsense_web/components/ui/popover.tsx b/surfsense_web/components/ui/popover.tsx
index 0d2759543..b3b92542d 100644
--- a/surfsense_web/components/ui/popover.tsx
+++ b/surfsense_web/components/ui/popover.tsx
@@ -39,4 +39,4 @@ function PopoverAnchor({ ...props }: React.ComponentProps;
}
-export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
diff --git a/surfsense_web/components/ui/sheet.tsx b/surfsense_web/components/ui/sheet.tsx
index 6ba22350e..58eb54965 100644
--- a/surfsense_web/components/ui/sheet.tsx
+++ b/surfsense_web/components/ui/sheet.tsx
@@ -122,11 +122,11 @@ function SheetDescription({
export {
Sheet,
- SheetTrigger,
SheetClose,
SheetContent,
- SheetHeader,
+ SheetDescription,
SheetFooter,
+ SheetHeader,
SheetTitle,
- SheetDescription,
+ SheetTrigger,
};
diff --git a/surfsense_web/components/ui/table.tsx b/surfsense_web/components/ui/table.tsx
index f88d2f592..fa170ec86 100644
--- a/surfsense_web/components/ui/table.tsx
+++ b/surfsense_web/components/ui/table.tsx
@@ -89,4 +89,4 @@ function TableCaption({ className, ...props }: React.ComponentProps<"caption">)
);
}
-export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
diff --git a/surfsense_web/components/ui/tabs.tsx b/surfsense_web/components/ui/tabs.tsx
index 5dbfc4bc5..29c95eaf7 100644
--- a/surfsense_web/components/ui/tabs.tsx
+++ b/surfsense_web/components/ui/tabs.tsx
@@ -52,4 +52,4 @@ const TabsContent = React.forwardRef<
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
-export { Tabs, TabsList, TabsTrigger, TabsContent };
+export { Tabs, TabsContent, TabsList, TabsTrigger };
diff --git a/surfsense_web/components/ui/tooltip.tsx b/surfsense_web/components/ui/tooltip.tsx
index 4253c50d4..b21d5f005 100644
--- a/surfsense_web/components/ui/tooltip.tsx
+++ b/surfsense_web/components/ui/tooltip.tsx
@@ -82,4 +82,4 @@ function TooltipContent({
);
}
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts
index 4b6d69883..7067ee025 100644
--- a/surfsense_web/lib/apis/connectors-api.service.ts
+++ b/surfsense_web/lib/apis/connectors-api.service.ts
@@ -426,6 +426,6 @@ export interface ObsidianStats {
last_sync_at: string | null;
}
-export type { SlackChannel, DiscordChannel };
+export type { DiscordChannel, SlackChannel };
export const connectorsApiService = new ConnectorsApiService();
diff --git a/surfsense_web/svgr.d.ts b/surfsense_web/svgr.d.ts
index ada7f47c5..2486ffa48 100644
--- a/surfsense_web/svgr.d.ts
+++ b/surfsense_web/svgr.d.ts
@@ -1,5 +1,6 @@
declare module "*.svg" {
import type { FC, SVGProps } from "react";
+
const content: FC>;
export default content;
}
From 98515e7d3877d3adf70891407852c653917ce153 Mon Sep 17 00:00:00 2001
From: Azan Ali
Date: Wed, 3 Jun 2026 15:58:01 +0500
Subject: [PATCH 07/11] fix(web): gate sidebar dropdown download items under
SSO auth
Extract downloadAllowed = !isDesktop && !isSSOAuth() and apply it to
all four download surfaces, including the two profile dropdown items
that were missed in the original commit.
Co-Authored-By: Claude Sonnet 4.6
---
.../components/layout/ui/sidebar/SidebarUserProfile.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
index fddd9e8df..cf959eabc 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx
@@ -146,7 +146,8 @@ export function SidebarUserProfile({
const displayName = user.name || user.email.split("@")[0];
const downloadUrl = primary?.url ?? GITHUB_RELEASES_URL;
const downloadLabel = t("download_for_os", { os });
- const showDownloadCta = !isDesktop && isDesktopViewport && !isSSOAuth();
+ const downloadAllowed = !isDesktop && !isSSOAuth();
+ const showDownloadCta = downloadAllowed && isDesktopViewport;
const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => {
setLocale(newLocale);
@@ -334,7 +335,7 @@ export function SidebarUserProfile({
- {!isDesktop && (
+ {downloadAllowed && (
@@ -519,7 +520,7 @@ export function SidebarUserProfile({
- {!isDesktop && (
+ {downloadAllowed && (
From 939e7a0879f09e87d0f2585a399aaff4c5078a4a Mon Sep 17 00:00:00 2001
From: Ahtisahm Shahid
Date: Wed, 3 Jun 2026 16:09:11 +0500
Subject: [PATCH 08/11] fix(ci): install libreadline-dev before pnpm install in
TS quality job
@rocicorp/zero-sqlite3 requires readline/readline.h to compile its
native extension. The GitHub Actions Ubuntu runner doesn't include
libreadline-dev by default, causing pnpm install to fail with a gyp
build error before biome can even run.
---
.github/workflows/code-quality.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index b5d80b9a0..a9314ae4e 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -238,6 +238,10 @@ jobs:
extension:
- 'surfsense_browser_extension/**'
+ - name: Install native build dependencies
+ if: steps.frontend-changes.outputs.web == 'true'
+ run: sudo apt-get install -y libreadline-dev
+
- name: Install dependencies for web
if: steps.frontend-changes.outputs.web == 'true'
working-directory: surfsense_web
From b76e9ce0c45f84426c0ddb39eb40593660ef4d4e Mon Sep 17 00:00:00 2001
From: Azan Ali
Date: Wed, 3 Jun 2026 18:23:09 +0500
Subject: [PATCH 09/11] fix(surfsense): use hard redirect on SSO callback to
fix blank display name
Soft navigation (router.replace) preserved the React tree after the SSO
cookie handoff, leaving currentUserAtom stuck with enabled:false for the
entire session. A full page reload ensures the token is in localStorage
before any atom evaluates.
Co-Authored-By: Claude Sonnet 4.6
---
surfsense_web/app/(home)/page.tsx | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx
index 14ff98be0..19b364024 100644
--- a/surfsense_web/app/(home)/page.tsx
+++ b/surfsense_web/app/(home)/page.tsx
@@ -1,6 +1,5 @@
"use client";
-import { useRouter } from "next/navigation";
import { useEffect } from "react";
import {
@@ -26,11 +25,9 @@ import {
* redirect take the user where they need to go.
*/
export default function HomePage() {
- const router = useRouter();
-
useEffect(() => {
if (getBearerToken()) {
- router.replace("/dashboard");
+ window.location.href = "/dashboard";
return;
}
@@ -42,13 +39,13 @@ export default function HomePage() {
setBearerToken(token);
if (refreshToken) setRefreshToken(refreshToken);
clearSSOCookies();
- router.replace("/dashboard");
+ window.location.href = "/dashboard";
return;
}
// No JWT anywhere → start the SSO flow.
window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/proxy-login`;
- }, [router]);
+ }, []);
// Splash — neutral background, no UI flash during the redirect dance.
return ;
From d025520ef7dd80feb007dde728ce811b33cdafc4 Mon Sep 17 00:00:00 2001
From: Usama Sadiq
Date: Thu, 4 Jun 2026 19:11:21 +0500
Subject: [PATCH 10/11] Update PR template to standard format
Co-Authored-By: Claude Opus 4.6
---
.github/PULL_REQUEST_TEMPLATE.md | 46 +++++++++++---------------------
1 file changed, 16 insertions(+), 30 deletions(-)
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 970c93e21..7c4ba90c7 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,41 +1,27 @@
-
+## Related Ticket
+
## Description
-
+
-## Motivation and Context
-
-
-FIX #
-
-
-## Screenshots
-
-
-## API Changes
-
-- [ ] This PR includes API changes
-
-## Change Type
-
+## Type of Change
- [ ] Bug fix
- [ ] New feature
-- [ ] Performance improvement
+- [ ] Improvement / Enhancement
- [ ] Refactoring
+- [ ] Performance improvement
- [ ] Documentation
-- [ ] Dependency/Build system
-- [ ] Breaking change
-- [ ] Other (specify):
+- [ ] Infrastructure / CI
-## Testing Performed
-
+## Testing
+
- [ ] Tested locally
-- [ ] Manual/QA verification
+- [ ] New / updated tests included
+
+## Screenshots
+
## Checklist
-
-- [ ] Follows project coding standards and conventions
-- [ ] Documentation updated as needed
-- [ ] Dependencies updated as needed
-- [ ] No lint/build errors or new warnings
-- [ ] All relevant tests are passing
\ No newline at end of file
+- [ ] No lint or build errors
+- [ ] All existing tests pass
+- [ ] Documentation updated (if needed)
From 7c2c7f646d28b86a9df3ce22b91f46462dd674b2 Mon Sep 17 00:00:00 2001
From: Usama Sadiq
Date: Tue, 7 Jul 2026 23:24:37 +0500
Subject: [PATCH 11/11] Add corporate_id check to ProxyAuth middleware
---
surfsense_backend/app/config/__init__.py | 4 +++
.../app/middleware/proxy_auth.py | 29 ++++++++++++++++++-
2 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py
index aefae6491..3c980f07f 100644
--- a/surfsense_backend/app/config/__init__.py
+++ b/surfsense_backend/app/config/__init__.py
@@ -690,6 +690,10 @@ def is_cloud(cls) -> bool:
os.getenv("SMB_DEFAULT_WORKSPACE_NAME") or os.getenv("SMB_NAME") or ""
).strip()
+ # Corporate tenant ID for cross-tenant isolation. When set, the proxy-auth
+ # middleware rejects access tokens whose custom:corporate_id does not match.
+ SMB_CORPORATE_ID = (os.getenv("SMB_CORPORATE_ID") 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/middleware/proxy_auth.py b/surfsense_backend/app/middleware/proxy_auth.py
index 7d0f7f97b..98551b2ce 100644
--- a/surfsense_backend/app/middleware/proxy_auth.py
+++ b/surfsense_backend/app/middleware/proxy_auth.py
@@ -2,13 +2,14 @@
import secrets
import unicodedata
+import jwt
from fastapi_users.db import SQLAlchemyUserDatabase
from fastapi_users.password import PasswordHelper # singleton below
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
-from starlette.responses import Response
+from starlette.responses import JSONResponse, Response
from app.config import config
from app.db import User, async_session_maker
@@ -33,6 +34,29 @@ def _coerce_bypass_paths(setting) -> list[str]:
return list(setting)
+def _check_corporate_id(request) -> bool:
+ """Verify that the caller's access token belongs to this deployment's tenant.
+
+ When ``SMB_CORPORATE_ID`` is configured, only corporate tokens whose
+ ``custom:corporate_id`` claim matches the expected value are allowed.
+ Individual (non-corporate) tokens are rejected. When the setting is
+ empty the check is skipped entirely for backward compatibility.
+ """
+ expected = getattr(config, "SMB_CORPORATE_ID", "")
+ if not expected:
+ return True
+ access_token = request.headers.get("x-auth-request-access-token")
+ if not access_token:
+ return False
+ try:
+ claims = jwt.decode(access_token, options={"verify_signature": False})
+ except Exception:
+ return False
+ if claims.get("custom:is_corporate") != "true":
+ return False
+ return claims.get("custom:corporate_id") == expected
+
+
def _is_bypass_path(path: str, bypass_paths: list[str]) -> bool:
# Match exact path OR a true subpath (e.g. /health/ready) but NOT a path that
# merely starts with the same characters (e.g. /healthz must NOT bypass /health).
@@ -98,6 +122,9 @@ async def dispatch(
)
return await call_next(request)
+ if not _check_corporate_id(request):
+ return JSONResponse(status_code=403, content={"error": "access_denied"})
+
user = await self._resolve_user(_normalise_email(raw_email), request)
# Respect deactivated accounts — mPass authentication does not