Stove-pipe RAG retrieval to prevent cross-tenant leakage (#40) (#41)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 9s
Deploy Beta / docker (push) Successful in 18s
Deploy Beta / deploy-beta (push) Successful in 46s

## Summary

- Closes [#40](#40)
- Aligns chat/RAG with the abc_worker stove-pipe pattern ([b13cec8](b13cec88f9)): immutable `ChatCompanyScope` per turn, conversation ownership validation, fail-closed Chroma filters
- Prefer ASGI/JWT identity over client email; never bind identity from `conversation_id` alone
- Close `ConversationDetailView` IDOR (prompts only for `request.user`)

## Changes

- New `services/chat_tenant_scope.py` with frozen `ChatCompanyScope` + ownership checks
- WebSocket consumers (`consumers.py` / `consumers_graph.py`) validate scope before `get_messages` / RAG
- `search_documents` requires a workspace (no more `filter: None` over the shared collection)
- Ingest writes `company_id` metadata (retrieval still keys on `workspace_id` for back-compat)
- Legacy `get_retriever` always applies a workspace filter

## Test plan

- [x] `manage.py test chat_backend.tests.test_chat_tenant_scope chat_backend.tests.test_consumers chat_backend.tests.test_services_rag chat_backend.tests.test_views_conversations`
- [ ] Manual: user A cannot stream RAG context from user B `conversation_id`
- [ ] Manual: RAG still returns own-company docs after deploy (existing vectors with `workspace_id` only)
- [ ] Follow-up: FE can send JWT `token`/`access` on WS payloads for stronger identity bindingReviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
westfarn committed 2026-08-01 12:35:00 -07:00
1 parent eedc842b08
commit 2e9e95e16c
9 files changed
+507 -90

No files matched your search

+72 -26
View File
@@ -18,6 +18,14 @@ from .models import Conversation, Prompt, PromptMetric, DocumentWorkspace, Custo
from .serializers import PromptSerializer
from .services.llm_service import AsyncLLMService
from .services.rag_services import AsyncRAGService
from .services.chat_tenant_scope import (
ChatTenantScopeError,
asgi_user_or_none,
create_conversation_for_user,
get_workspace_for_scope,
resolve_chat_company_scope,
resolve_chat_user as resolve_chat_user_sync,
)
from .services.title_generator import title_generator
from .services.moderation_classifier import moderation_classifier, ModerationLabel
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
@@ -41,29 +49,22 @@ PROMPT_CLASSIFIER = PromptClassifier()
# --- Database Helpers (Reused) ---
@database_sync_to_async
def create_conversation(prompt, email, title):
conversation = Conversation.objects.create(title=title)
user = CustomUser.objects.get(email=email)
conversation.user_id = user.id
conversation.save()
return conversation.id
def create_conversation(prompt, email, title, user=None):
if user is None:
user = CustomUser.objects.get(email=email)
return create_conversation_for_user(user, title)
@database_sync_to_async
def resolve_chat_user(email=None, conversation_id=None):
if email:
user = CustomUser.objects.filter(email__iexact=email).first()
if user:
return user
if conversation_id:
conversation = (
Conversation.objects.select_related("user")
.filter(id=conversation_id)
.first()
)
if conversation and conversation.user_id:
return conversation.user
return None
def resolve_chat_user(
email=None, conversation_id=None, token=None, authenticated_user=None
):
return resolve_chat_user_sync(
email=email,
token=token,
authenticated_user=authenticated_user,
conversation_id=conversation_id,
)
@database_sync_to_async
@@ -78,9 +79,19 @@ def enforce_feature_gate(user, feature):
assert_feature_allowed(user, feature)
@database_sync_to_async
def get_workspace(conversation_id):
conversation = Conversation.objects.get(id=conversation_id)
return DocumentWorkspace.objects.get(company=conversation.user.company)
def get_workspace(conversation_id, user=None):
if user is None:
raise ChatTenantScopeError(
"Authenticated chat user is required.",
code="user_not_found",
)
scope = resolve_chat_company_scope(user, conversation_id)
return get_workspace_for_scope(scope)
@database_sync_to_async
def resolve_tenant_scope(user, conversation_id=None):
return resolve_chat_company_scope(user, conversation_id)
@database_sync_to_async
def get_messages(conversation_id, prompt, file_string: str = None, file_type: str = ""):
@@ -284,7 +295,8 @@ async def generation_node(state: ChatState) -> ChatState:
if prompt_type == PromptType.RAG:
service = AsyncRAGService()
workspace = await get_workspace(conversation_id)
chat_user = state.get("chat_user")
workspace = await get_workspace(conversation_id, user=chat_user)
generator = service.generate_response(messages, prompt_instance.message, workspace)
return {"response_generator": generator}
@@ -349,6 +361,7 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
message = normalize_user_message(data.get("message", None))
conversation_id = data.get("conversation_id", None)
email = data.get("email", None)
token = data.get("token") or data.get("access")
file = data.get("file", None)
file_type = data.get("fileType", "")
@@ -365,7 +378,10 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
return
chat_user = await resolve_chat_user(
email=email, conversation_id=conversation_id
email=email,
conversation_id=conversation_id,
token=token,
authenticated_user=asgi_user_or_none(self.scope.get("user")),
)
if chat_user is None:
await self.send_json_message(
@@ -396,7 +412,37 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
if not conversation_id:
title = await title_generator.generate_async(message)
conversation_id = await create_conversation(message, email, title)
conversation_id = await create_conversation(
message, email, title, user=chat_user
)
try:
tenant_scope = await resolve_tenant_scope(chat_user, conversation_id)
except ChatTenantScopeError as exc:
logger.warning(
"websocket tenant validation failed conversation_id=%s user_id=%s code=%s",
conversation_id,
chat_user.id,
exc.code,
)
await self.send_json_message(
json.dumps(
{
"type": "error",
"code": exc.code,
"content": exc.message,
}
)
)
return
logger.info(
"chat_scope_validated conversation_id=%s user_id=%s company_id=%s workspace_id=%s",
tenant_scope.conversation_id,
tenant_scope.user_id,
tenant_scope.company_id,
tenant_scope.workspace_id,
)
if conversation_id:
print("Conversation ID: ", conversation_id)