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