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:
parent
e2ceba4310
commit
e7483269a0
46
app.py
46
app.py
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 -->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user