security(chat): Izolacja sesji czata i anonimizacja danych

ZMIANY BEZPIECZEŃSTWA:

1. Defense in depth w NordaBizChatEngine:
   - send_message() - wymaga user_id i weryfikuje właściciela rozmowy
   - get_conversation_history() - opcjonalna walidacja user_id
   - Logowanie prób nieautoryzowanego dostępu

2. Anonimizacja w panelu admina:
   - Usunięto wyświetlanie treści zapytań użytkowników
   - Zastąpiono statystykami: długość, kategoria tematyczna
   - Zachowano AI responses (publiczne dane firm)

3. Ochrona prywatności:
   - Użytkownicy NIE mogą zobaczyć zapytań innych użytkowników
   - Admini widzą tylko zanonimizowane statystyki
   - Audit logging dla prób nieautoryzowanego dostępu

Odblokowuje zadanie #10 (Baza wiedzy Norda GPT).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-28 21:56:35 +01:00
parent e2ceba4310
commit e7483269a0
3 changed files with 125 additions and 11 deletions

46
app.py
View File

@ -4604,7 +4604,8 @@ def chat_get_history(conversation_id):
db.close()
chat_engine = NordaBizChatEngine()
history = chat_engine.get_conversation_history(conversation_id)
# SECURITY: Pass user_id for defense-in-depth ownership validation
history = chat_engine.get_conversation_history(conversation_id, user_id=current_user.id)
return jsonify({
'success': True,
@ -7032,6 +7033,7 @@ def chat_analytics():
db = SessionLocal()
try:
from sqlalchemy import func, desc
from datetime import date
# Basic stats
total_conversations = db.query(AIChatConversation).count()
@ -7048,11 +7050,46 @@ def chat_analytics():
AIChatMessage.feedback_rating.isnot(None)
).order_by(desc(AIChatMessage.feedback_at)).limit(20).all()
# Popular queries (user messages)
recent_queries = db.query(AIChatMessage).filter_by(role='user').order_by(
# SECURITY: Query statistics only - do NOT expose raw user content
# This protects user privacy while still providing useful analytics
# Raw message content is NOT passed to the template
# Query categories/stats instead of raw content
from sqlalchemy import func, case
query_stats = {
'total_today': db.query(AIChatMessage).filter(
AIChatMessage.role == 'user',
func.date(AIChatMessage.created_at) == date.today()
).count(),
'avg_length': db.query(func.avg(func.length(AIChatMessage.content))).filter(
AIChatMessage.role == 'user'
).scalar() or 0,
'queries_with_company': db.query(AIChatMessage).filter(
AIChatMessage.role == 'user',
AIChatMessage.content.ilike('%firma%')
).count(),
'queries_with_contact': db.query(AIChatMessage).filter(
AIChatMessage.role == 'user',
AIChatMessage.content.ilike('%kontakt%') | AIChatMessage.content.ilike('%telefon%') | AIChatMessage.content.ilike('%email%')
).count()
}
# Recent queries - anonymized (show only metadata, not content)
recent_queries_raw = db.query(AIChatMessage).filter_by(role='user').order_by(
desc(AIChatMessage.created_at)
).limit(50).all()
# Anonymize: show length and timestamp only
recent_queries = [
{
'length': len(q.content) if q.content else 0,
'created_at': q.created_at,
'has_company_mention': 'firma' in (q.content or '').lower(),
'has_contact_request': any(kw in (q.content or '').lower() for kw in ['kontakt', 'telefon', 'email', 'www'])
}
for q in recent_queries_raw
]
# Calculate satisfaction rate
satisfaction_rate = (positive_feedback / feedback_count * 100) if feedback_count > 0 else 0
@ -7066,7 +7103,8 @@ def chat_analytics():
negative_feedback=negative_feedback,
satisfaction_rate=round(satisfaction_rate, 1),
recent_feedback=recent_feedback,
recent_queries=recent_queries
recent_queries=recent_queries,
query_stats=query_stats # SECURITY: Aggregated stats only
)
finally:
db.close()

View File

@ -159,24 +159,31 @@ class NordaBizChatEngine:
self,
conversation_id: int,
user_message: str,
user_id: Optional[int] = None
user_id: int
) -> AIChatMessage:
"""
Send message and get AI response
SECURITY: Validates that user_id owns the conversation before allowing messages.
This is defense-in-depth - API routes should also validate ownership.
Args:
conversation_id: Conversation ID
user_message: User's message text
user_id: User ID for cost tracking (optional)
user_id: User ID (required for ownership validation and cost tracking)
Returns:
AIChatMessage: AI response message
Raises:
ValueError: If conversation not found
PermissionError: If user doesn't own the conversation
"""
db = SessionLocal()
start_time = time.time()
try:
# Get conversation
# SECURITY: Get conversation with ownership check
conversation = db.query(AIChatConversation).filter_by(
id=conversation_id
).first()
@ -184,6 +191,14 @@ class NordaBizChatEngine:
if not conversation:
raise ValueError(f"Conversation {conversation_id} not found")
# SECURITY: Verify user owns this conversation (defense in depth)
if conversation.user_id != user_id:
logger.warning(
f"SECURITY: User {user_id} attempted to access conversation {conversation_id} "
f"owned by user {conversation.user_id}"
)
raise PermissionError("Access denied: You don't own this conversation")
# Save user message
user_msg = AIChatMessage(
conversation_id=conversation_id,
@ -246,20 +261,46 @@ class NordaBizChatEngine:
def get_conversation_history(
self,
conversation_id: int
conversation_id: int,
user_id: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get all messages in conversation
SECURITY: If user_id is provided, validates ownership before returning messages.
This is defense-in-depth - API routes should also validate ownership.
Args:
conversation_id: Conversation ID
user_id: User ID for ownership validation (optional for backwards compatibility,
but strongly recommended for security)
Returns:
List of message dicts
Raises:
ValueError: If conversation not found
PermissionError: If user_id provided and user doesn't own the conversation
"""
db = SessionLocal()
try:
# SECURITY: Verify ownership if user_id provided
if user_id is not None:
conversation = db.query(AIChatConversation).filter_by(
id=conversation_id
).first()
if not conversation:
raise ValueError(f"Conversation {conversation_id} not found")
if conversation.user_id != user_id:
logger.warning(
f"SECURITY: User {user_id} attempted to read history of conversation {conversation_id} "
f"owned by user {conversation.user_id}"
)
raise PermissionError("Access denied: You don't own this conversation")
messages = db.query(AIChatMessage).filter_by(
conversation_id=conversation_id
).order_by(AIChatMessage.created_at).all()

View File

@ -169,14 +169,44 @@
</div>
</div>
<!-- Recent Queries -->
<!-- Query Statistics (Privacy Protected) -->
<div class="section">
<h2>Ostatnie zapytania użytkowników</h2>
<h2>Statystyki zapytań <span style="font-size: var(--font-size-sm); color: var(--text-secondary);">🔒 Dane zanonimizowane</span></h2>
<div class="stats-grid" style="margin-bottom: var(--spacing-lg);">
<div class="stat-card">
<div class="stat-value">{{ query_stats.total_today }}</div>
<div class="stat-label">Zapytań dzisiaj</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ query_stats.avg_length|int }}</div>
<div class="stat-label">Śr. długość (znaki)</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ query_stats.queries_with_company }}</div>
<div class="stat-label">O firmach</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ query_stats.queries_with_contact }}</div>
<div class="stat-label">O kontaktach</div>
</div>
</div>
<h3 style="margin-top: var(--spacing-lg);">Ostatnia aktywność (zanonimizowana)</h3>
{% if recent_queries %}
<ul class="query-list">
{% for query in recent_queries %}
<li class="query-item">
<div class="query-text">{{ query.content }}</div>
<div class="query-text">
<span style="color: var(--text-secondary);"></span>
Zapytanie ({{ query.length }} znaków)
{% if query.has_company_mention %}
<span class="badge" style="background: var(--primary-light); color: var(--primary); padding: 2px 6px; border-radius: 4px; font-size: 10px; margin-left: 8px;">FIRMA</span>
{% endif %}
{% if query.has_contact_request %}
<span class="badge" style="background: var(--success-light); color: var(--success); padding: 2px 6px; border-radius: 4px; font-size: 10px; margin-left: 4px;">KONTAKT</span>
{% endif %}
</div>
<div class="query-meta">
{{ query.created_at.strftime('%d.%m %H:%M') }}
</div>
@ -186,6 +216,11 @@
{% else %}
<p class="text-muted">Brak zapytań</p>
{% endif %}
<div style="margin-top: var(--spacing-lg); padding: var(--spacing-md); background: var(--background); border-radius: var(--radius); font-size: var(--font-size-sm); color: var(--text-secondary);">
<strong>🔒 Ochrona prywatności:</strong> Treść zapytań użytkowników nie jest wyświetlana w panelu admina.
Widoczne są tylko zanonimizowane statystyki (długość, kategorie tematyczne).
</div>
</div>
<!-- Feedback Section -->