Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/backend/db/mongo_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,9 @@ def assert_destructive_db_operation_allowed(operation: str) -> None:
# Server-side persistence for prompts waiting behind active chat turns.
CHAT_PROMPT_QUEUE_COLLECTION_NAME = "chat_prompt_queue"

# Restart-safe, actor-bound per-window organisation selections.
WINDOW_SESSION_BINDINGS_COLLECTION_NAME = "window_session_bindings"

# --- Client Initialization ---
# REFACTORING_NOTE: We maintain separate clients for real MongoDB vs mongomock.
# This prevents test suites from “poisoning” the process by enabling VON_USE_MOCK_DB
Expand Down Expand Up @@ -2107,6 +2110,23 @@ def _ensure_chat_prompt_queue_indexes(coll: Collection) -> None:
"active_conversation_key": {"$exists": True, "$type": "string"}
},
)
if "active_legacy_submission_key_unique" not in existing_indexes:
coll.create_index(
[("active_legacy_submission_key", ASCENDING)],
name="active_legacy_submission_key_unique",
unique=True,
partialFilterExpression={
"active_legacy_submission_key": {
"$exists": True,
"$type": "string",
}
},
)
if "legacy_submission_expires_at" not in existing_indexes:
coll.create_index(
[("legacy_submission_expires_at", ASCENDING)],
name="legacy_submission_expires_at",
)
if "queued_global_slot_unique" not in existing_indexes:
coll.create_index(
[("queued_global_slot", ASCENDING)],
Expand Down Expand Up @@ -2153,6 +2173,18 @@ def _ensure_chat_prompt_queue_indexes(coll: Collection) -> None:
coll.create_index([("updated_at", DESCENDING)], name="updated_at_-1")


def _ensure_window_session_binding_indexes(coll: Collection) -> None:
existing_indexes = {idx["name"] for idx in coll.list_indexes()}
if "user_id_1" not in existing_indexes:
coll.create_index([("user_id", ASCENDING)], name="user_id_1")
if "expires_at_ttl" not in existing_indexes:
coll.create_index(
[("expires_at", ASCENDING)],
name="expires_at_ttl",
expireAfterSeconds=0,
)


def _ensure_gmail_outbound_quota_indexes(coll: Collection) -> None:
existing_indexes = {idx["name"] for idx in coll.list_indexes()}
if "expires_at_ttl" not in existing_indexes:
Expand Down Expand Up @@ -2453,6 +2485,19 @@ def get_chat_prompt_queue_collection() -> Collection | None:
return None


def get_window_session_binding_collection() -> Collection | None:
"""Return durable actor-owned browser-window organisation selections."""

db = get_db()
if db is not None:
return _ensure_collection_indexes_once(
db,
WINDOW_SESSION_BINDINGS_COLLECTION_NAME,
_ensure_window_session_binding_indexes,
)
return None


def get_gmail_outbound_quota_collection() -> Collection | None:
"""Return atomic outbound Gmail counters with bounded TTL retention."""

Expand Down
10 changes: 9 additions & 1 deletion src/backend/security/access_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,10 @@ def get_effective_organisation_concept_id() -> Optional[str]:
try:
window_session_id = request.headers.get("X-Von-Window-Session")
if window_session_id:
from ..services.window_session_context_service import get_effective_context
from ..services.window_session_context_service import (
WindowSessionContextUnavailable,
get_effective_context,
)

user_for_context = get_effective_user_concept_id() or session.get(
"user_concept_id"
Expand All @@ -466,10 +469,15 @@ def get_effective_organisation_concept_id() -> Optional[str]:
window_session_id,
dict(session),
user_for_context,
require_known_window=True,
)
org_id = _normalise_stored_org_concept_id(effective.get("organisation_id"))
if org_id:
return org_id
except WindowSessionContextUnavailable:
# An explicit unknown/expired/other-actor selector must never inherit
# the browser-wide organisation selected by another tab.
return None
except Exception:
pass
for key in ("organisation_concept_id", "org_concept_id", "org_id"):
Expand Down
33 changes: 27 additions & 6 deletions src/backend/server/routes/auth_routes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from flask import Blueprint, request, redirect, session, url_for, jsonify
from flask import Blueprint, current_app, request, redirect, session, url_for, jsonify

from ...auth_service import GoogleAuthService
from ...services.settings_service import (
Expand Down Expand Up @@ -539,18 +539,39 @@ def logout():
from ...services.window_session_context_service import (
delete_window_context_if_owned,
)

delete_window_context_if_owned(
request.headers.get("X-Von-Window-Session"),
user_id,
from ...services.window_session_binding_store_service import (
WindowSessionBindingStoreUnavailable,
)

cleanup_deferred = False
try:
delete_window_context_if_owned(
request.headers.get("X-Von-Window-Session"),
user_id,
)
except WindowSessionBindingStoreUnavailable:
# Authentication must still end if derived binding storage is down.
# The selector carries no authority without a newly authenticated
# matching actor and expires independently.
cleanup_deferred = True
current_app.logger.warning(
"Logout completed while window-session binding cleanup was unavailable"
)

# Clear the entire session to ensure a clean logout
session.clear()

print(f"[auth_logout] User {user_email} logged out successfully")

return jsonify({"success": True, "message": "Logged out successfully"})
return jsonify(
{
"success": True,
"message": "Logged out successfully",
"window_context_cleanup": (
"deferred" if cleanup_deferred else "completed"
),
}
)


@auth_bp.route("/test-popup")
Expand Down
Loading
Loading