diff --git a/backend/app/main.py b/backend/app/main.py index 182330f..32799e3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -390,7 +390,7 @@ async def root(): # Include V2 API routers from app.routers import auth, tasks, admin, translate -from fastapi import UploadFile, File, Depends, HTTPException, status +from fastapi import UploadFile, File, Depends, HTTPException, status, Request from sqlalchemy.orm import Session import hashlib @@ -399,6 +399,23 @@ from app.models.user import User from app.models.task import TaskFile from app.schemas.task import UploadResponse, TaskStatusEnum from app.services.task_service import task_service +from app.services.audit_service import audit_service + + +def get_client_ip(request: Request) -> str: + """Extract client IP address from request""" + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip + return request.client.host if request.client else "unknown" + + +def get_user_agent(request: Request) -> str: + """Extract user agent from request""" + return request.headers.get("User-Agent", "unknown")[:500] app.include_router(auth.router) app.include_router(tasks.router) @@ -409,6 +426,7 @@ app.include_router(translate.router) # File upload endpoint @app.post("/api/v2/upload", response_model=UploadResponse, tags=["Upload"], summary="Upload file for OCR") async def upload_file( + request: Request, file: UploadFile = File(..., description="File to upload (PNG, JPG, PDF, etc.)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) @@ -470,6 +488,25 @@ async def upload_file( logger.info(f"Uploaded file {file.filename} ({file_size} bytes) for task {task.task_id}, user {current_user.email}") + # Log file upload event + audit_service.log_event( + db=db, + event_type="file_upload", + event_category="file", + description=f"File uploaded: {file.filename} ({file_size} bytes)", + user_id=current_user.id, + ip_address=get_client_ip(request), + user_agent=get_user_agent(request), + resource_type="task", + resource_id=task.task_id, + success=True, + metadata={ + "filename": file.filename, + "file_size": file_size, + "file_type": file.content_type + } + ) + return { "task_id": task.task_id, "filename": file.filename, diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index ee6ce39..d8916f8 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -405,6 +405,22 @@ async def trigger_cleanup( f"{result['total_files_deleted']} files, {result['total_bytes_freed']} bytes" ) + # Log admin cleanup action + audit_service.log_event( + db=db, + event_type="admin_cleanup", + event_category="admin", + description=f"Manual cleanup: {result['total_files_deleted']} files, {result['total_bytes_freed']} bytes freed", + user_id=admin_user.id, + success=True, + metadata={ + "files_deleted": result['total_files_deleted'], + "bytes_freed": result['total_bytes_freed'], + "users_processed": result['users_processed'], + "max_files_per_user": files_to_keep + } + ) + return { "success": True, "message": "Cleanup completed successfully", diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 049d167..d23fe73 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -17,6 +17,7 @@ from app.models.user import User from app.models.session import Session as UserSession from app.schemas.auth import LoginRequest, Token, UserResponse from app.services.external_auth_service import external_auth_service +from app.services.audit_service import audit_service logger = logging.getLogger(__name__) @@ -66,6 +67,17 @@ async def login( logger.warning( f"External auth failed for user {login_data.username}: {error_msg}" ) + # Log failed login attempt + audit_service.log_event( + db=db, + event_type="auth_login", + event_category="authentication", + description=f"Login failed for {login_data.username}: {error_msg}", + ip_address=get_client_ip(request), + user_agent=get_user_agent(request), + success=False, + error_message=error_msg + ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=error_msg or "Authentication failed", @@ -151,6 +163,20 @@ async def login( expires_delta=internal_token_expires ) + # Log successful login + audit_service.log_event( + db=db, + event_type="auth_login", + event_category="authentication", + description=f"User logged in successfully", + user_id=user.id, + ip_address=get_client_ip(request), + user_agent=get_user_agent(request), + resource_type="session", + resource_id=str(session.id), + success=True + ) + return { "access_token": internal_access_token, "token_type": "bearer", @@ -165,6 +191,7 @@ async def login( @router.post("/logout", summary="User logout") async def logout( + request: Request, session_id: Optional[int] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) @@ -174,9 +201,6 @@ async def logout( - **session_id**: Session ID to logout (optional, logs out all if not provided) """ - # TODO: Implement proper current_user dependency from JWT token - # For now, this is a placeholder - if session_id: # Logout specific session session = db.query(UserSession).filter( @@ -188,6 +212,20 @@ async def logout( db.delete(session) db.commit() logger.info(f"Logged out session {session_id} for user {current_user.email}") + + # Log logout event + audit_service.log_event( + db=db, + event_type="auth_logout", + event_category="authentication", + description=f"User logged out session {session_id}", + user_id=current_user.id, + ip_address=get_client_ip(request), + user_agent=get_user_agent(request), + resource_type="session", + resource_id=str(session_id), + success=True + ) return {"message": "Logged out successfully"} else: raise HTTPException( @@ -206,6 +244,19 @@ async def logout( db.commit() logger.info(f"Logged out all {count} sessions for user {current_user.email}") + + # Log logout event + audit_service.log_event( + db=db, + event_type="auth_logout", + event_category="authentication", + description=f"User logged out all {count} sessions", + user_id=current_user.id, + ip_address=get_client_ip(request), + user_agent=get_user_agent(request), + success=True, + metadata={"sessions_count": count} + ) return {"message": f"Logged out {count} sessions"} diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index a9ab3d1..0aa8114 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -9,7 +9,7 @@ from pathlib import Path import shutil import hashlib -from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, BackgroundTasks, Request from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session import json @@ -47,6 +47,7 @@ from app.services.task_service import task_service from app.services.file_access_service import file_access_service from app.services.ocr_service import OCRService from app.services.service_pool import get_service_pool, PoolConfig +from app.services.audit_service import audit_service # Import dual-track components try: @@ -67,6 +68,22 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v2/tasks", tags=["Tasks"]) +def get_client_ip(request: Request) -> str: + """Extract client IP address from request""" + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip + return request.client.host if request.client else "unknown" + + +def get_user_agent(request: Request) -> str: + """Extract user agent from request""" + return request.headers.get("User-Agent", "unknown")[:500] + + def process_task_ocr( task_id: str, task_db_id: int, @@ -518,6 +535,7 @@ async def update_task( @router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_task( task_id: str, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -539,6 +557,20 @@ async def delete_task( ) logger.info(f"Deleted task {task_id} for user {current_user.email}") + + # Log task deletion + audit_service.log_event( + db=db, + event_type="task_delete", + event_category="task", + description=f"Task deleted", + user_id=current_user.id, + ip_address=get_client_ip(request), + user_agent=get_user_agent(request), + resource_type="task", + resource_id=task_id, + success=True + ) return None diff --git a/openspec/changes/enable-audit-logging/proposal.md b/openspec/changes/enable-audit-logging/proposal.md new file mode 100644 index 0000000..3768694 --- /dev/null +++ b/openspec/changes/enable-audit-logging/proposal.md @@ -0,0 +1,52 @@ +# Enable Audit Logging + +## Summary +Activate the existing audit logging infrastructure by adding `audit_service.log_event()` calls to key system operations. The audit log table and service already exist but are not being used. + +## Motivation +- Audit logs page exists but shows no data because events are not being recorded +- Security compliance requires tracking of authentication and administrative actions +- Administrators need visibility into system usage and potential security issues + +## Current State +- `AuditLog` model exists in `backend/app/models/audit_log.py` +- `AuditService` with `log_event()` method exists in `backend/app/services/audit_service.py` +- `AuditLogsPage` frontend exists at `/admin/audit-logs` +- Admin API endpoint `GET /api/v2/admin/audit-logs` exists +- **Problem**: No code calls `audit_service.log_event()` - logs are always empty + +## Proposed Changes + +### Events to Log + +| Event Type | Category | Location | Description | +|------------|----------|----------|-------------| +| `auth_login` | authentication | auth.py | User login (success/failure) | +| `auth_logout` | authentication | auth.py | User logout | +| `auth_token_refresh` | authentication | auth.py | Token refresh | +| `task_create` | task | tasks.py | Task created | +| `task_process` | task | tasks.py | Task processing started | +| `task_complete` | task | tasks.py | Task completed | +| `task_delete` | task | tasks.py | Task deleted | +| `admin_cleanup` | admin | admin.py | Manual cleanup triggered | +| `admin_view_users` | admin | admin.py | Admin viewed user list | +| `file_upload` | file | main.py | File uploaded | + +### Implementation Approach +1. Add helper function to extract client info (IP, user agent) from Request +2. Add `audit_service.log_event()` calls to each operation point +3. Ensure all events capture: user_id, IP address, user agent, resource info + +## Non-Goals +- Creating new audit log model (already exists) +- Changing audit log API endpoints (already work) +- Modifying frontend audit logs page (already complete) + +## Affected Specs +- None (infrastructure already in place) + +## Testing +- Verify audit logs appear after login/logout +- Verify task operations are logged +- Verify admin actions are logged +- Check audit logs page displays new entries diff --git a/openspec/changes/enable-audit-logging/tasks.md b/openspec/changes/enable-audit-logging/tasks.md new file mode 100644 index 0000000..95dc7c6 --- /dev/null +++ b/openspec/changes/enable-audit-logging/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Enable Audit Logging + +## 1. Helper Utilities +- [x] 1.1 Create helper function to extract client info (IP, user agent) from FastAPI Request + +## 2. Authentication Events +- [x] 2.1 Log `auth_login` on successful/failed login in auth.py +- [x] 2.2 Log `auth_logout` on logout in auth.py +- [ ] 2.3 Log `auth_token_refresh` on token refresh (deferred - low priority) + +## 3. Task Events +- [ ] 3.1 Log `task_create` when task is created (deferred - covered by file_upload) +- [ ] 3.2 Log `task_process` when task processing starts (deferred - background task) +- [ ] 3.3 Log `task_complete` when task completes (deferred - background task) +- [x] 3.4 Log `task_delete` when task is deleted + +## 4. Admin Events +- [x] 4.1 Log `admin_cleanup` when manual cleanup is triggered +- [ ] 4.2 Log `admin_view_users` when admin views user list (deferred - low priority) + +## 5. File Events +- [x] 5.1 Log `file_upload` when file is uploaded + +## 6. Testing +- [ ] 6.1 Verify login creates audit log entry +- [ ] 6.2 Verify task operations create audit log entries +- [ ] 6.3 Verify audit logs page shows entries +- [x] 6.4 Test backend module imports + +## Notes +- Core audit events implemented: login, logout, task delete, file upload, admin cleanup +- Background task events (task_process, task_complete) deferred - would require significant refactoring +- Low priority admin events deferred for future implementation