Implement compliance decryption package with HTML report, streamline audit and envelope routes

This commit is contained in:
2026-01-10 19:41:49 +03:00
Unverified
parent fd4c00057c
commit 0b683e3c83
14 changed files with 1229 additions and 429 deletions
+197 -100
View File
@@ -10,9 +10,11 @@ Handles:
import logging
import json
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
@@ -26,10 +28,26 @@ from ..service_calls import (
get_compliance_public_key,
process_message_with_files_in_messaging_service,
store_encrypted_file,
init_resumable_upload_in_storage,
get_resumable_upload_status_in_storage,
upload_resumable_chunk_in_storage,
complete_resumable_upload_in_storage,
get_resumable_upload_data_in_storage,
delete_resumable_upload_in_storage,
)
from .messaging import messagingManager, convert_dm_envelope
logger = logging.getLogger("uvicorn.error")
def _compliance_public_key_required() -> bool:
try:
from services.shared.message_retention import get_message_retention
except ImportError:
from backend.services.shared.message_retention import get_message_retention # type: ignore
return not get_message_retention().never_store_compliance_mek()
router = APIRouter(prefix="/dm", tags=["Direct Messages"])
@@ -51,8 +69,10 @@ class SendEncryptedMessageRequest(BaseModel):
transport_ciphertext_b64: str
sender_public_key_b64: str
recipient_public_key_b64: str
client_message_id: Optional[str] = None
reply_to_id: Optional[int] = None
files: list[FileModel] = Field(default_factory=list, alias="transport_files")
uploaded_file_ids: list[str] = Field(default_factory=list, alias="uploaded_file_ids")
class Config:
allow_population_by_field_name = True
@@ -67,17 +87,29 @@ class EditEncryptedMessageRequest(BaseModel):
recipient_public_key_b64: str
class InitResumableUploadRequest(BaseModel):
filename: str
total_size: int
recipient_id: int
chunk_size: Optional[int] = None
class UploadChunkRequest(BaseModel):
offset: int
data_b64: str
# ============================================================================
# Key Management Endpoint
# ============================================================================
@router.get("/key/transport/public")
async def get_transport_public_key_endpoint():
async def get_transport_public_key_endpoint(request: Request):
"""
Get the current messaging service ephemeral transport public key.
Clients use this key to encrypt their messages with X25519 + ChaCha20-Poly1305.
Returns:
{
"key_id": "key-identifier",
@@ -85,8 +117,11 @@ async def get_transport_public_key_endpoint():
"created_at": "unix-timestamp"
}
"""
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
try:
return await get_messaging_transport_public_key()
result = await get_messaging_transport_public_key()
return result
except Exception as e:
logger.error("Failed to fetch transport public key: %s", e)
raise HTTPException(
@@ -95,27 +130,64 @@ async def get_transport_public_key_endpoint():
)
@router.get("/key/compliance/public")
async def get_compliance_public_key_endpoint():
"""
Get the compliance system public key (for MEK wrapping).
@router.post("/upload/init")
async def init_resumable_upload(
request: InitResumableUploadRequest,
current_user: User = Depends(get_current_user),
):
if request.total_size <= 0:
raise HTTPException(status_code=400, detail="total_size must be > 0")
if current_user.id == request.recipient_id:
raise HTTPException(status_code=400, detail="Cannot send files to yourself")
payload = await init_resumable_upload_in_storage(
filename=request.filename,
total_size=request.total_size,
allowed_user_ids=[current_user.id, request.recipient_id],
chunk_size=request.chunk_size,
)
return payload
@router.get("/upload/{upload_id}")
async def get_resumable_upload_status(
upload_id: str,
current_user: User = Depends(get_current_user),
):
return await get_resumable_upload_status_in_storage(upload_id, current_user.id)
@router.patch("/upload/{upload_id}")
async def upload_resumable_chunk(
upload_id: str,
request: UploadChunkRequest,
current_user: User = Depends(get_current_user),
):
return await upload_resumable_chunk_in_storage(
upload_id=upload_id,
user_id=current_user.id,
offset=request.offset,
data_b64=request.data_b64,
)
@router.post("/upload/{upload_id}/complete")
async def complete_resumable_upload(
upload_id: str,
current_user: User = Depends(get_current_user),
):
return await complete_resumable_upload_in_storage(upload_id, current_user.id)
@router.delete("/upload/{upload_id}")
async def delete_resumable_upload(
upload_id: str,
current_user: User = Depends(get_current_user),
):
return await delete_resumable_upload_in_storage(upload_id, current_user.id)
This key is generated offline on an air-gapped machine and used to wrap MEKs
so the compliance system can decrypt archived messages for audit.
Returns:
{
"public_key_b64": "base64-encoded-key"
}
"""
try:
return await get_compliance_public_key()
except Exception as e:
logger.error("Failed to fetch compliance public key: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch compliance key"
)
# ============================================================================
@@ -170,13 +242,33 @@ async def send_encrypted_message(
# Fetch compliance public key and process through messaging service
compliance_key_response = await get_compliance_public_key()
compliance_public_key_b64 = compliance_key_response.get("public_key_b64")
if not compliance_public_key_b64:
compliance_public_key_b64 = compliance_key_response.get("public_key_b64") or ""
if _compliance_public_key_required() and not compliance_public_key_b64:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve compliance key"
)
all_transport_files: list[dict[str, object]] = [
{
"encrypted_file_data_b64": f.encrypted_file_data_b64,
"filename": f.filename,
"file_size": f.file_size,
}
for f in request.files
]
for upload_id in request.uploaded_file_ids:
uploaded_payload = await get_resumable_upload_data_in_storage(upload_id, current_user.id)
all_transport_files.append(
{
"encrypted_file_data_b64": uploaded_payload["encrypted_file_data_b64"],
"filename": uploaded_payload["filename"],
"file_size": uploaded_payload["file_size"],
"upload_id": upload_id,
}
)
processed = await process_message_with_files_in_messaging_service(
client_public_key_b64=request.client_public_key_b64,
transport_nonce_b64=request.transport_nonce_b64,
@@ -184,7 +276,13 @@ async def send_encrypted_message(
compliance_public_key_b64=compliance_public_key_b64,
sender_public_key_b64=request.sender_public_key_b64,
recipient_public_key_b64=request.recipient_public_key_b64,
transport_files=[{"encrypted_file_data_b64": f.encrypted_file_data_b64} for f in request.files],
transport_files=[
{
"encrypted_file_data_b64": str(f["encrypted_file_data_b64"]),
"filename": str(f.get("filename", "file")),
}
for f in all_transport_files
],
)
logger.info(
@@ -213,14 +311,14 @@ async def send_encrypted_message(
# We persist per-file nonce (for AES-GCM) but do not persist per-file wrapped MEKs.
try:
file_results: list[dict] = processed.get("files", []) or []
if len(file_results) != len(request.files):
if len(file_results) != len(all_transport_files):
raise HTTPException(status_code=500, detail="File processing count mismatch")
for i, tf in enumerate(request.files):
for i, tf in enumerate(all_transport_files):
fr = file_results[i]
file_storage_result = await store_encrypted_file(
encrypted_file_data_b64=fr["ciphertext"],
filename=tf.filename,
filename=str(tf["filename"]),
content_type="application/octet-stream",
sender_id=current_user.id,
recipient_id=request.recipient_id,
@@ -231,12 +329,18 @@ async def send_encrypted_message(
sender_id=current_user.id,
recipient_id=dm_envelope.recipient_id,
path=file_storage_result.get("path") or f"/uploads/files/encrypted/{file_storage_result['file_id']}",
name=Path(tf.filename).name,
name=Path(str(tf["filename"])).name,
nonce_b64=fr["nonce"],
)
db.add(df)
db.commit()
for upload_id in request.uploaded_file_ids:
try:
await delete_resumable_upload_in_storage(upload_id, current_user.id)
except Exception as cleanup_error:
logger.warning("Failed to cleanup resumable upload %s: %s", upload_id, cleanup_error)
except HTTPException:
raise
except Exception:
@@ -257,6 +361,8 @@ async def send_encrypted_message(
await messagingManager.send_update_to_user(dm_envelope.recipient_id, "dmNew", recipient_payload, db)
sender_payload = convert_dm_envelope(db, dm_envelope, dm_envelope.sender_id)
if request.client_message_id:
sender_payload["client_message_id"] = request.client_message_id
await messagingManager.send_update_to_user(dm_envelope.sender_id, "dmNew", sender_payload, db)
return {
@@ -264,6 +370,7 @@ async def send_encrypted_message(
"sender_id": dm_envelope.sender_id,
"recipient_id": dm_envelope.recipient_id,
"timestamp": dm_envelope.timestamp.isoformat(),
"client_message_id": request.client_message_id,
"reply_to_id": dm_envelope.reply_to_id,
}
@@ -296,47 +403,32 @@ async def extract_message_for_compliance(
to an air-gapped machine for decryption using the compliance private key.
"""
# Log compliance access attempt
client_ip = getattr(request.client, "host", "unknown") if request.client else "unknown"
log_security(
"compliance_access_attempt",
"warning",
username=current_user.username,
user_id=current_user.id,
message_id=message_id,
ip=client_ip,
)
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
log_security("compliance_access_attempt", "warning",
username=current_user.username, user_id=current_user.id,
message_id=message_id, ip=client_ip)
# Security check: only user ID 1 can access this
if current_user.id != 1:
log_security(
"compliance_access_denied",
"error",
username=current_user.username,
user_id=current_user.id,
message_id=message_id,
ip=client_ip,
reason="Unauthorized user (compliance officer access required)",
)
log_security("compliance_access_denied", "error",
username=current_user.username, user_id=current_user.id,
message_id=message_id, ip=client_ip,
reason="Unauthorized user (compliance officer access required)")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. This endpoint is restricted to compliance officers.",
detail="Access denied. This endpoint is restricted to compliance officers."
)
# Find the message
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
if not envelope:
log_security(
"compliance_access_failed",
"warning",
username=current_user.username,
user_id=current_user.id,
message_id=message_id,
ip=client_ip,
reason="Message not found",
)
log_security("compliance_access_failed", "warning",
username=current_user.username, user_id=current_user.id,
message_id=message_id, ip=client_ip,
reason="Message not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found",
detail="Message not found"
)
# Get sender and recipient usernames for logging
@@ -378,8 +470,6 @@ async def extract_message_for_compliance(
"edited_by_username": edited_by_user.username if edited_by_user else "unknown",
"previous_ciphertext_b64": edit_entry.previous_ciphertext_b64,
"previous_iv_b64": edit_entry.previous_iv_b64,
"previous_sender_wrapped_mek_b64": edit_entry.previous_sender_wrapped_mek_b64,
"previous_recipient_wrapped_mek_b64": edit_entry.previous_recipient_wrapped_mek_b64,
"previous_compliance_wrapped_mek_b64": edit_entry.previous_compliance_wrapped_mek_b64,
})
@@ -396,21 +486,14 @@ async def extract_message_for_compliance(
"total_edits": len(edit_history_data),
"extraction_timestamp": datetime.now().isoformat(),
"extracted_by_user_id": current_user.id,
"compliance_system_ready": envelope.compliance_wrapped_mek_b64 is not None,
"compliance_system_ready": envelope.compliance_wrapped_mek_b64 is not None
}
log_security(
"compliance_extraction_success",
"info",
username=current_user.username,
user_id=current_user.id,
message_id=message_id,
sender_id=envelope.sender_id,
recipient_id=envelope.recipient_id,
sender_username=sender_username,
recipient_username=recipient_username,
ip=client_ip,
)
log_security("compliance_extraction_success", "info",
username=current_user.username, user_id=current_user.id,
message_id=message_id, sender_id=envelope.sender_id,
recipient_id=envelope.recipient_id, sender_username=sender_username,
recipient_username=recipient_username, ip=client_ip)
return {
"status": "success",
@@ -419,8 +502,8 @@ async def extract_message_for_compliance(
"instructions": [
"Transfer this data to an air-gapped machine",
"Use compliance_decryption.py decrypt --input-file <json_file>",
"Keep the compliance private key offline at all times",
],
"Keep the compliance private key offline at all times"
]
}
@@ -475,7 +558,7 @@ async def get_encrypted_conversation(
detail="User not found"
)
# Fetch messages in both directions, sorted by timestamp
# Fetch messages in both directions, sorted by timestamp (exclude deleted)
messages = (
db.query(DMEnvelope)
.filter(
@@ -486,7 +569,8 @@ async def get_encrypted_conversation(
| (
(DMEnvelope.sender_id == other_user_id)
& (DMEnvelope.recipient_id == current_user.id)
)
),
DMEnvelope.deleted_at.is_(None) # Exclude soft-deleted messages
)
.order_by(DMEnvelope.timestamp.desc())
.limit(limit)
@@ -572,9 +656,10 @@ async def get_owner_compliance_view(
)
try:
# Fetch all messages
# Fetch all non-deleted messages
messages = (
db.query(DMEnvelope)
.filter(DMEnvelope.deleted_at.is_(None)) # Exclude soft-deleted messages
.order_by(DMEnvelope.timestamp.desc())
.all()
)
@@ -679,8 +764,6 @@ async def get_dm_edit_history_for_compliance(
"dm_envelope_id": entry.message_id,
"previous_ciphertext_b64": entry.previous_ciphertext_b64,
"previous_iv_b64": entry.previous_iv_b64,
"previous_sender_wrapped_mek_b64": entry.previous_sender_wrapped_mek_b64,
"previous_recipient_wrapped_mek_b64": entry.previous_recipient_wrapped_mek_b64,
"previous_compliance_wrapped_mek_b64": entry.previous_compliance_wrapped_mek_b64,
"edited_at": entry.edited_at.isoformat(),
"edited_by_username": edited_by_user.username if edited_by_user else "unknown",
@@ -757,7 +840,10 @@ async def edit_encrypted_message(
"""
try:
# Find the message
msg = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
msg = db.query(DMEnvelope).filter(
DMEnvelope.id == message_id,
DMEnvelope.deleted_at.is_(None) # Can't edit deleted messages
).first()
if not msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -773,8 +859,8 @@ async def edit_encrypted_message(
# Fetch compliance public key and process through messaging service
compliance_key_response = await get_compliance_public_key()
compliance_public_key_b64 = compliance_key_response.get("public_key_b64")
if not compliance_public_key_b64:
compliance_public_key_b64 = compliance_key_response.get("public_key_b64") or ""
if _compliance_public_key_required() and not compliance_public_key_b64:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve compliance key"
@@ -791,22 +877,11 @@ async def edit_encrypted_message(
transport_files=[], # No file support for edits currently
)
# Store edit history in compliance storage before updating
edit_history = DMEditHistory(
message_id=msg.id,
dm_envelope_id=msg.id, # Match existing DB schema
previous_ciphertext_b64=msg.ciphertext_b64,
previous_iv_b64=msg.iv_b64,
previous_sender_wrapped_mek_b64=msg.sender_wrapped_mek_b64,
previous_recipient_wrapped_mek_b64=msg.recipient_wrapped_mek_b64,
previous_compliance_wrapped_mek_b64=msg.compliance_wrapped_mek_b64 or "",
edited_by=current_user.id,
edited_by_user_id=current_user.id # Match existing DB schema
)
db.add(edit_history)
# Update the message with new processed content
# Update the message with new processed content (commit first so edit always succeeds)
processed_msg = processed["message"]
prev_ciphertext = msg.ciphertext_b64
prev_iv = msg.iv_b64
prev_wrapped_mek = msg.compliance_wrapped_mek_b64 or ""
msg.ciphertext_b64 = processed_msg["ciphertext"]
msg.iv_b64 = processed_msg["nonce"]
msg.sender_wrapped_mek_b64 = processed["sender_wrapped_mek"]
@@ -817,6 +892,26 @@ async def edit_encrypted_message(
db.commit()
db.refresh(msg)
# Best-effort: store edit history for compliance (table may not exist yet)
try:
edit_history = DMEditHistory(
message_id=msg.id,
dm_envelope_id=msg.id,
previous_ciphertext_b64=prev_ciphertext,
previous_iv_b64=prev_iv,
previous_compliance_wrapped_mek_b64=prev_wrapped_mek,
edited_by=current_user.id,
edited_by_user_id=current_user.id,
)
db.add(edit_history)
db.commit()
except Exception as history_err:
db.rollback()
logger.warning(
"Could not store DM edit history (table dm_edit_history may not exist): %s",
history_err,
)
logger.info(
"Edited encrypted message msg_id=%s by user_id=%s",
message_id,
@@ -884,7 +979,9 @@ async def delete_encrypted_message(
detail="Cannot delete others' messages"
)
db.delete(msg)
# Soft delete: set deleted_at timestamp instead of hard delete
from datetime import datetime
msg.deleted_at = datetime.now()
db.commit()
logger.info(
+67 -211
View File
@@ -10,12 +10,9 @@ import time
import unicodedata
from collections import defaultdict, deque
from difflib import SequenceMatcher
from types import SimpleNamespace
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request, status
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from ..dependencies import get_current_user, get_db
from .account import convert_user
@@ -55,7 +52,6 @@ def _get_file_storage_url() -> str:
or "http://127.0.0.1:8302"
)
_SPAM_WINDOW_SECONDS = 45
_SPAM_SIMILARITY_THRESHOLD = 0.88
_SPAM_MESSAGE_LIMIT = 5
@@ -628,135 +624,6 @@ async def mark_messages_read(request: Request, read_request: MarkReadRequest, cu
return {"status": "success", "updated": int(updated_count)}
@router.post("/dm/send-legacy")
@rate_limit_per_ip("20/minute")
async def dm_send(
request: Request,
payload: dict | None = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
# Multipart support
dm_payload: str | None = Form(default=None),
files: list[UploadFile] = File(default=[]),
fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files
):
if dm_payload and payload is None:
try:
payload = json.loads(dm_payload)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dm_payload JSON")
if payload is None:
raise HTTPException(status_code=400, detail="Missing payload")
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
for key in required:
if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}")
try:
recipient_id = int(payload["recipientId"])
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid recipientId")
if recipient_id <= 0:
raise HTTPException(status_code=400, detail="Invalid recipientId")
if recipient_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot send DM to yourself")
# Verify recipient exists
recipient = db.query(User).filter(User.id == recipient_id).first()
if not recipient or recipient.deleted or recipient.suspended:
raise HTTPException(status_code=404, detail="Recipient not found")
env = DMEnvelope(
sender_id=current_user.id,
recipient_id=recipient_id,
iv_b64=payload["iv"],
ciphertext_b64=payload["ciphertext"],
sender_wrapped_mek_b64=payload.get("wrappedMk", ""),
recipient_wrapped_mek_b64=payload.get("wrappedMk", ""),
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
)
db.add(env)
db.commit()
db.refresh(env)
# Save encrypted files if any (no processing)
if files:
# Validate total size
total_size = 0
for file in files:
if hasattr(file, "size") and file.size is not None:
total_size += int(file.size)
else:
data = await file.read()
file.file.seek(0)
total_size += len(data)
if total_size > MAX_TOTAL_SIZE:
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
names: list[str] = []
if fileNames:
try:
decoded = json.loads(fileNames)
if isinstance(decoded, list):
names = [str(x) for x in decoded]
except Exception:
names = []
for i, file in enumerate(files):
provided = names[i] if i < len(names) else None
# Sanitize provided name to avoid path traversal
if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided):
provided = None
original_name = provided or Path(file.filename or "file").name
# Save using provided/original name to allow client to reference path directly
safe_name = uid = uuid.uuid4().hex
out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}"
out_path = FILES_ENCRYPTED_DIR / out_name
content = await file.read()
with open(out_path, "wb") as f:
f.write(content)
# Save DM file record
df = DMFile(
message_id=env.id,
sender_id=current_user.id,
recipient_id=env.recipient_id,
path=f"/api/uploads/files/encrypted/{out_name}",
name=original_name
)
db.add(df)
db.commit()
db.refresh(env)
# Send push notification for DM
try:
await push_service.send_dm_notification(db, env, current_user)
except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
# Send user-specific WebSocket updates (each user gets only their MEK)
recipient_payload = convert_dm_envelope(db, env, env.recipient_id)
await messagingManager.send_update_to_user(env.recipient_id, "dmNew", recipient_payload, db)
sender_payload = convert_dm_envelope(db, env, env.sender_id)
await messagingManager.send_update_to_user(env.sender_id, "dmNew", sender_payload, db)
log_dm(
"message_sent",
dm_envelope_id=env.id,
sender_id=current_user.id,
sender_username=current_user.username,
recipient_id=env.recipient_id,
attachment_count=len(env.files or []),
reply_to=env.reply_to_id,
)
return {"status": "ok", "id": env.id}
@router.get("/dm/fetch")
@@ -1116,14 +983,23 @@ class MessaggingSocketManager:
if websocket.client_state.name == "CONNECTED":
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
async def _get_next_sequence(self, user_id: int) -> int:
async def _get_next_sequence(self, user_id: int, db: Session | None = None) -> int:
"""Get the next sequence number for a user (shared across all their connections) - thread-safe"""
if user_id not in self._sequence_lock:
self._sequence_lock[user_id] = asyncio.Lock()
async with self._sequence_lock[user_id]:
if user_id not in self.sequence_numbers:
self.sequence_numbers[user_id] = 0
# Initialize from database to avoid conflicts on restart
if db:
try:
from ..models import UpdateLog
latest = db.query(UpdateLog).filter(UpdateLog.user_id == user_id).order_by(UpdateLog.sequence.desc()).first()
self.sequence_numbers[user_id] = latest.sequence if latest else 0
except Exception:
self.sequence_numbers[user_id] = 0
else:
self.sequence_numbers[user_id] = 0
self.sequence_numbers[user_id] += 1
return self.sequence_numbers[user_id]
@@ -1226,7 +1102,7 @@ class MessaggingSocketManager:
logger.warning(f"Attempted to flush updates for unauthenticated websocket, skipping")
return
seq = await self._get_next_sequence(user_id)
seq = await self._get_next_sequence(user_id, db)
# Store updates in database for gap detection (only once per user per sequence)
if db:
@@ -1549,12 +1425,14 @@ async def chat_websocket(
# File serving proxy endpoints
# Proxy file requests to file_storage service
import httpx
@router.api_route("/uploads/files/normal/{filename:path}", methods=["GET"])
async def proxy_normal_file(
request: Request,
filename: str,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user)
):
"""Proxy file requests to file_storage service."""
mod = service_calls._get_file_storage_module()
@@ -1574,12 +1452,11 @@ async def proxy_normal_file(
try:
response = await client.get(target_url, headers=headers)
from fastapi.responses import Response
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.headers.get("content-type"),
media_type=response.headers.get("content-type")
)
except httpx.RequestError as e:
logger.error("Failed to proxy file request: %s", e)
@@ -1592,15 +1469,15 @@ async def test_proxy():
file_storage_url = _get_file_storage_url()
target_url = f"{file_storage_url}/health"
logger.info("Testing proxy to: %s", target_url)
logger.info(f"Testing proxy to: {target_url}")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
response = await client.get(target_url, follow_redirects=False)
logger.info("Test proxy response: %s", response.status_code)
logger.info(f"Test proxy response: {response.status_code}")
return {"status": "ok", "response_code": response.status_code}
except Exception as e:
logger.error("Test proxy failed: %s", e)
logger.error(f"Test proxy failed: {e}")
return {"status": "error", "error": str(e)}
@@ -1608,7 +1485,7 @@ async def test_proxy():
async def proxy_encrypted_file(
request: Request,
filename: str,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user)
):
"""Proxy file requests to file_storage service."""
mod = service_calls._get_file_storage_module()
@@ -1629,12 +1506,11 @@ async def proxy_encrypted_file(
try:
response = await client.get(target_url, headers=headers, follow_redirects=False)
from fastapi.responses import Response
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.headers.get("content-type"),
media_type=response.headers.get("content-type")
)
except Exception as e:
logger.error("Failed to proxy file request: %s", e)
@@ -1646,7 +1522,7 @@ async def get_message_edit_history_for_compliance(
request: Request,
message_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
db: Session = Depends(get_db)
):
"""
Get complete edit history for a public message (compliance access only).
@@ -1663,72 +1539,58 @@ async def get_message_edit_history_for_compliance(
Returns:
Complete edit history for the message
"""
client_ip = getattr(request.client, "host", "unknown") if request.client else "unknown"
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
# Log compliance access attempt
log_security(
"message_edit_history_access_attempt",
"warning",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
message_id=message_id,
)
log_security("message_edit_history_access_attempt", "warning",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
message_id=message_id)
# Only user_id 1 (compliance officer) can access
if current_user.id != 1:
log_security(
"message_edit_history_access_denied",
"error",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
reason="Unauthorized user (compliance officer access required)",
)
log_security("message_edit_history_access_denied", "error",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
reason="Unauthorized user (compliance officer access required)")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. This endpoint is restricted to compliance officers.",
detail="Access denied. This endpoint is restricted to compliance officers."
)
try:
# Get the original message
message = db.query(Message).filter(Message.id == message_id).first()
if not message:
log_security(
"message_edit_history_access_failed",
"warning",
user_id=current_user.id,
ip=client_ip,
message_id=message_id,
reason="Message not found",
)
log_security("message_edit_history_access_failed", "warning",
user_id=current_user.id,
ip=client_ip,
message_id=message_id,
reason="Message not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found",
detail="Message not found"
)
# Get edit history
edit_history = (
db.query(MessageEditHistory)
.filter(MessageEditHistory.message_id == message_id)
.order_by(MessageEditHistory.edited_at)
.all()
)
edit_history = db.query(MessageEditHistory).filter(
MessageEditHistory.message_id == message_id
).order_by(MessageEditHistory.edited_at).all()
# Convert to response format
history_entries = []
for entry in edit_history:
edited_by_user = db.query(User).filter(User.id == entry.edited_by_user_id).first()
history_entries.append(
{
"id": entry.id,
"message_id": entry.message_id,
"previous_content": entry.previous_content,
"edited_at": entry.edited_at.isoformat(),
"edited_by_username": edited_by_user.username if edited_by_user else "unknown",
"edited_by_user_id": entry.edited_by_user_id,
}
)
history_entries.append({
"id": entry.id,
"message_id": entry.message_id,
"previous_content": entry.previous_content,
"edited_at": entry.edited_at.isoformat(),
"edited_by_username": edited_by_user.username if edited_by_user else "unknown",
"edited_by_user_id": entry.edited_by_user_id
})
# Current message data
current_data = {
@@ -1736,25 +1598,22 @@ async def get_message_edit_history_for_compliance(
"content": message.content,
"user_id": message.user_id,
"timestamp": message.timestamp.isoformat(),
"is_edited": message.is_edited,
"is_edited": message.is_edited
}
result = {
"message_id": message_id,
"current_version": current_data,
"edit_history": history_entries,
"total_edits": len(history_entries),
"total_edits": len(history_entries)
}
log_security(
"message_edit_history_access_success",
"info",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
message_id=message_id,
edit_count=len(history_entries),
)
log_security("message_edit_history_access_success", "info",
user_id=current_user.id,
username=current_user.username,
ip=client_ip,
message_id=message_id,
edit_count=len(history_entries))
return result
@@ -1762,15 +1621,12 @@ async def get_message_edit_history_for_compliance(
raise
except Exception as e:
logger.exception("Error retrieving message edit history: %s", e)
log_security(
"message_edit_history_access_error",
"error",
user_id=current_user.id,
ip=client_ip,
message_id=message_id,
error=str(e),
)
log_security("message_edit_history_access_error", "error",
user_id=current_user.id,
ip=client_ip,
message_id=message_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve edit history",
)
detail="Failed to retrieve edit history"
)
+75 -5
View File
@@ -46,6 +46,39 @@ def _yes_no(flag: Any) -> str:
def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
# Handle compliance-related actions with beautiful formatting
if action == "compliance_access_attempt":
lines = [f"Compliance access attempt for message {fields.get('message_id', 'unknown')}"]
lines.append(f"User: {_format_user(fields)}")
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
return lines
if action == "compliance_access_denied":
lines = [f"Compliance access denied for message {fields.get('message_id', 'unknown')}"]
lines.append(f"User: {_format_user(fields)}")
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
return lines
if action == "compliance_access_failed":
lines = [f"Compliance access failed for message {fields.get('message_id', 'unknown')}"]
lines.append(f"User: {_format_user(fields)}")
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
return lines
if action == "compliance_extraction_success":
lines = [f"Compliance extraction successful for message {fields.get('message_id', 'unknown')}"]
lines.append(f"Officer: {_format_user(fields)}")
sender_id = fields.get("sender_id")
recipient_id = fields.get("recipient_id")
if sender_id is not None and recipient_id is not None:
lines.append(f"Message: {_format_user({'username': fields.get('sender_username'), 'user_id': sender_id})}{_format_user({'username': fields.get('recipient_username'), 'user_id': recipient_id})}")
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
return lines
if action == "login_success":
lines = [f"Login approved for {_format_user(fields)}"]
session = fields.get("session_id")
@@ -61,14 +94,14 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
if client_bits:
lines.append(f"Client: {', '.join(client_bits)}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
lines.append(f"IP: {fields['ip']}")
return lines
if action == "login_failed":
lines = [f"Login denied for {_format_user(fields)}"]
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
lines.append(f"IP: {fields['ip']}")
return lines
if action == "auth_bruteforce_detected":
lines = ["Brute-force login pattern detected"]
@@ -78,7 +111,7 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
for key, value in failures.items():
lines.append(f"{key}: {value}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
lines.append(f"IP: {fields['ip']}")
if fields.get("window_seconds"):
lines.append(f"Observation window: {fields['window_seconds']} seconds")
return lines
@@ -103,14 +136,14 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]:
lines = [f"Password changed for {_format_user(fields)}"]
lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
lines.append(f"IP: {fields['ip']}")
return lines
if action == "logout":
lines = [f"Logout recorded for {_format_user(fields)}"]
if fields.get("session_id"):
lines.append(f"Session: {fields['session_id']}")
if fields.get("ip"):
lines.append(f"IP address: {fields['ip']}")
lines.append(f"IP: {fields['ip']}")
return lines
if action == "admin_delete_user":
return [
@@ -367,6 +400,43 @@ def _render_access(action: str, fields: Dict[str, Any]) -> List[str]:
continue
lines.append(f"{key.replace('_', ' ').capitalize()}: {value}")
return lines
if action == "compliance_access_attempt":
lines = [f"Compliance access attempt for message {fields.get('message_id', 'unknown')}"]
lines.append(f"User: {_format_user(fields)}")
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
return lines
if action == "compliance_access_denied":
lines = [f"Compliance access denied for message {fields.get('message_id', 'unknown')}"]
lines.append(f"User: {_format_user(fields)}")
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
return lines
if action == "compliance_access_failed":
lines = [f"Compliance access failed for message {fields.get('message_id', 'unknown')}"]
lines.append(f"User: {_format_user(fields)}")
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
if fields.get("reason"):
lines.append(f"Reason: {fields['reason']}")
return lines
if action == "compliance_extraction_success":
lines = [f"Compliance extraction successful for message {fields.get('message_id', 'unknown')}"]
lines.append(f"Officer: {_format_user(fields)}")
sender_id = fields.get("sender_id")
recipient_id = fields.get("recipient_id")
if sender_id is not None and recipient_id is not None:
lines.append(f"Message: {_format_user({'username': fields.get('sender_username'), 'user_id': sender_id})}{_format_user({'username': fields.get('recipient_username'), 'user_id': recipient_id})}")
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
return lines
if action == "compliance_public_key_access":
lines = [f"Compliance public key accessed"]
if fields.get("ip"):
lines.append(f"IP: {fields['ip']}")
return lines
return [f"{action.replace('_', ' ').capitalize()}"] + [
f"{key.replace('_', ' ').capitalize()}: {value}"
for key, value in fields.items()