## 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:
1 parent
eedc842b08
commit
2e9e95e16c
9 files changed
+507
-90
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user