Feature - add logs for admin user
This commit is contained in:
@@ -6,8 +6,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.migrations import run_migrations
|
||||
from app.models.audit_log import AuditLog # noqa: F401
|
||||
from app.models.verification_token import VerificationToken # noqa: F401
|
||||
from app.routers import auth, documents, templates, users
|
||||
from app.routers import auth, documents, logs, templates, users
|
||||
from app.seed import seed_admin_user
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -35,6 +36,7 @@ app.add_middleware(
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(templates.router)
|
||||
app.include_router(documents.router)
|
||||
|
||||
|
||||
@@ -60,3 +60,55 @@ def run_migrations() -> None:
|
||||
"ON verification_tokens (code)"
|
||||
)
|
||||
)
|
||||
|
||||
if "audit_logs" not in tables:
|
||||
with engine.begin() as conn:
|
||||
if dialect == "postgresql":
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
username VARCHAR(50),
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource_type VARCHAR(50),
|
||||
resource_id INTEGER,
|
||||
details JSON,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE audit_logs (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
username VARCHAR(50),
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource_type VARCHAR(50),
|
||||
resource_id INTEGER,
|
||||
details JSON,
|
||||
ip_address VARCHAR(45),
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at "
|
||||
"ON audit_logs (created_at DESC)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_audit_logs_action "
|
||||
"ON audit_logs (action)"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from app.models.document import FilledDocument
|
||||
from app.models.template import DocumentTemplate, TemplateVariable
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.verification_token import TokenType, VerificationToken
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -11,4 +12,5 @@ __all__ = [
|
||||
"FilledDocument",
|
||||
"VerificationToken",
|
||||
"TokenType",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
24
backend/app/models/audit_log.py
Normal file
24
backend/app/models/audit_log.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
username: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(100), index=True)
|
||||
resource_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
resource_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
details: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, index=True
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.schemas import (
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_action
|
||||
from app.services.email_service import send_activation_email, send_password_reset_email
|
||||
from app.services.verification import create_verification_token, verify_code
|
||||
|
||||
@@ -32,7 +33,11 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register(user_data: UserCreate, db: Annotated[Session, Depends(get_db)]):
|
||||
def register(
|
||||
user_data: UserCreate,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
if user_data.password != user_data.confirm_password:
|
||||
raise HTTPException(status_code=400, detail="Passwords do not match")
|
||||
|
||||
@@ -52,6 +57,15 @@ def register(user_data: UserCreate, db: Annotated[Session, Depends(get_db)]):
|
||||
db.flush()
|
||||
|
||||
token = create_verification_token(db, user, TokenType.activation)
|
||||
log_action(
|
||||
db,
|
||||
"auth.register",
|
||||
user=user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
details={"email": user.email},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
send_activation_email(user.email, user.username, token.code)
|
||||
@@ -65,6 +79,7 @@ def register(user_data: UserCreate, db: Annotated[Session, Depends(get_db)]):
|
||||
@router.post("/activate", response_model=MessageResponse)
|
||||
def activate_account(
|
||||
data: ActivateAccountRequest,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
user = verify_code(db, data.email, data.code, TokenType.activation)
|
||||
@@ -72,6 +87,14 @@ def activate_account(
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired activation code")
|
||||
|
||||
user.is_active = True
|
||||
log_action(
|
||||
db,
|
||||
"auth.activate",
|
||||
user=user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(message="Account activated. You can now log in.")
|
||||
@@ -80,6 +103,7 @@ def activate_account(
|
||||
@router.post("/resend-activation", response_model=MessageResponse)
|
||||
def resend_activation(
|
||||
data: ForgotPasswordRequest,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
user = get_user_by_email(db, data.email)
|
||||
@@ -92,6 +116,14 @@ def resend_activation(
|
||||
raise HTTPException(status_code=400, detail="Account is already activated")
|
||||
|
||||
token = create_verification_token(db, user, TokenType.activation)
|
||||
log_action(
|
||||
db,
|
||||
"auth.resend_activation",
|
||||
user=user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
send_activation_email(user.email, user.username, token.code)
|
||||
|
||||
@@ -103,11 +135,20 @@ def resend_activation(
|
||||
@router.post("/forgot-password", response_model=MessageResponse)
|
||||
def forgot_password(
|
||||
data: ForgotPasswordRequest,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
user = get_user_by_email(db, data.email)
|
||||
if user and user.is_active:
|
||||
token = create_verification_token(db, user, TokenType.password_reset)
|
||||
log_action(
|
||||
db,
|
||||
"auth.forgot_password",
|
||||
user=user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
send_password_reset_email(user.email, user.username, token.code)
|
||||
|
||||
@@ -119,6 +160,7 @@ def forgot_password(
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
def reset_password(
|
||||
data: ResetPasswordRequest,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
if data.password != data.confirm_password:
|
||||
@@ -129,6 +171,14 @@ def reset_password(
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired reset code")
|
||||
|
||||
user.hashed_password = get_password_hash(data.password)
|
||||
log_action(
|
||||
db,
|
||||
"auth.reset_password",
|
||||
user=user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(message="Password updated. You can now log in.")
|
||||
@@ -136,6 +186,7 @@ def reset_password(
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
request: Request,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
@@ -151,6 +202,15 @@ def login(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account not activated. Check your email for the activation code.",
|
||||
)
|
||||
log_action(
|
||||
db,
|
||||
"auth.login",
|
||||
user=user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
token = create_access_token(data={"sub": user.username})
|
||||
return Token(access_token=token)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.schemas import (
|
||||
PreviewResponse,
|
||||
)
|
||||
from app.services.access import accessible_document, accessible_template, is_admin
|
||||
from app.services.audit import get_client_ip, log_action
|
||||
from app.services.docx_renderer import docx_to_html, export_to_pdf, render_docx
|
||||
|
||||
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
||||
@@ -90,6 +91,7 @@ def get_document(
|
||||
def update_document(
|
||||
document_id: int,
|
||||
update: FilledDocumentUpdate,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
@@ -114,6 +116,15 @@ def update_document(
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
_rerender_document(doc, template)
|
||||
log_action(
|
||||
db,
|
||||
"document.update",
|
||||
user=current_user,
|
||||
resource_type="document",
|
||||
resource_id=doc.id,
|
||||
details={"name": doc.name},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
return _doc_to_response(doc)
|
||||
@@ -122,6 +133,7 @@ def update_document(
|
||||
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_document(
|
||||
document_id: int,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
@@ -129,6 +141,16 @@ def delete_document(
|
||||
if not doc or not accessible_document(db, current_user, document_id):
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
log_action(
|
||||
db,
|
||||
"document.delete",
|
||||
user=current_user,
|
||||
resource_type="document",
|
||||
resource_id=doc.id,
|
||||
details={"name": doc.name},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
|
||||
if doc.rendered_docx_path and os.path.exists(doc.rendered_docx_path):
|
||||
os.remove(doc.rendered_docx_path)
|
||||
|
||||
@@ -139,6 +161,7 @@ def delete_document(
|
||||
@router.post("", response_model=FilledDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_document(
|
||||
data: FilledDocumentCreate,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
@@ -158,6 +181,16 @@ def create_document(
|
||||
rendered_docx_path=output_path,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
log_action(
|
||||
db,
|
||||
"document.create",
|
||||
user=current_user,
|
||||
resource_type="document",
|
||||
resource_id=doc.id,
|
||||
details={"name": doc.name, "template_id": doc.template_id},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
return _doc_to_response(doc)
|
||||
@@ -186,6 +219,7 @@ def preview_document(
|
||||
@router.get("/{document_id}/export/docx")
|
||||
def export_docx(
|
||||
document_id: int,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
@@ -204,6 +238,17 @@ def export_docx(
|
||||
if not doc.rendered_docx_path:
|
||||
raise HTTPException(status_code=404, detail="Rendered file not found")
|
||||
|
||||
log_action(
|
||||
db,
|
||||
"document.export_docx",
|
||||
user=current_user,
|
||||
resource_type="document",
|
||||
resource_id=doc.id,
|
||||
details={"name": doc.name},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return FileResponse(
|
||||
doc.rendered_docx_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
@@ -214,6 +259,7 @@ def export_docx(
|
||||
@router.get("/{document_id}/export/pdf")
|
||||
def export_pdf(
|
||||
document_id: int,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
@@ -240,4 +286,15 @@ def export_pdf(
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
log_action(
|
||||
db,
|
||||
"document.export_pdf",
|
||||
user=current_user,
|
||||
resource_type="document",
|
||||
resource_id=doc.id,
|
||||
details={"name": doc.name},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{doc.name}.pdf")
|
||||
|
||||
48
backend/app/routers/logs.py
Normal file
48
backend/app/routers/logs.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_admin
|
||||
from app.database import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
from app.schemas import AuditLogResponse
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
|
||||
def _log_to_response(log: AuditLog) -> AuditLogResponse:
|
||||
details = None
|
||||
if log.details:
|
||||
try:
|
||||
details = json.loads(log.details)
|
||||
except json.JSONDecodeError:
|
||||
details = {"raw": log.details}
|
||||
return AuditLogResponse(
|
||||
id=log.id,
|
||||
user_id=log.user_id,
|
||||
username=log.username,
|
||||
action=log.action,
|
||||
resource_type=log.resource_type,
|
||||
resource_id=log.resource_id,
|
||||
details=details,
|
||||
ip_address=log.ip_address,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AuditLogResponse])
|
||||
def list_logs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
action: str | None = None,
|
||||
):
|
||||
query = db.query(AuditLog).order_by(AuditLog.created_at.desc())
|
||||
if action:
|
||||
query = query.filter(AuditLog.action.contains(action))
|
||||
logs = query.offset(offset).limit(limit).all()
|
||||
return [_log_to_response(log) for log in logs]
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.services.access import (
|
||||
is_admin,
|
||||
manageable_template,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_action
|
||||
from app.services.docx_parser import parse_docx_template
|
||||
|
||||
router = APIRouter(prefix="/api/templates", tags=["templates"])
|
||||
@@ -124,6 +125,7 @@ def get_template(
|
||||
@router.get("/{template_id}/download")
|
||||
def download_template_source(
|
||||
template_id: int,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
@@ -133,6 +135,17 @@ def download_template_source(
|
||||
if not os.path.exists(template.file_path):
|
||||
raise HTTPException(status_code=404, detail="Source file not found")
|
||||
|
||||
log_action(
|
||||
db,
|
||||
"template.download",
|
||||
user=current_user,
|
||||
resource_type="template",
|
||||
resource_id=template.id,
|
||||
details={"name": template.name},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return FileResponse(
|
||||
template.file_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
@@ -144,6 +157,7 @@ def download_template_source(
|
||||
def update_template(
|
||||
template_id: int,
|
||||
update: TemplateUpdate,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
@@ -151,9 +165,19 @@ def update_template(
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
for field, value in update.model_dump(exclude_unset=True).items():
|
||||
changes = update.model_dump(exclude_unset=True)
|
||||
for field, value in changes.items():
|
||||
setattr(template, field, value)
|
||||
|
||||
log_action(
|
||||
db,
|
||||
"template.update",
|
||||
user=current_user,
|
||||
resource_type="template",
|
||||
resource_id=template.id,
|
||||
details={"name": template.name, "changes": changes},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
return _template_to_response(template, current_user)
|
||||
@@ -161,6 +185,7 @@ def update_template(
|
||||
|
||||
@router.post("", response_model=DocumentTemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_template(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
file: UploadFile = File(...),
|
||||
@@ -207,6 +232,15 @@ async def create_template(
|
||||
)
|
||||
)
|
||||
|
||||
log_action(
|
||||
db,
|
||||
"template.create",
|
||||
user=current_user,
|
||||
resource_type="template",
|
||||
resource_id=template.id,
|
||||
details={"name": template.name, "is_public": template.is_public},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
return _template_to_response(template, current_user)
|
||||
@@ -260,6 +294,7 @@ def update_variable(
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_template(
|
||||
template_id: int,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
@@ -267,6 +302,16 @@ def delete_template(
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
log_action(
|
||||
db,
|
||||
"template.delete",
|
||||
user=current_user,
|
||||
resource_type="template",
|
||||
resource_id=template.id,
|
||||
details={"name": template.name},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
|
||||
if os.path.exists(template.file_path):
|
||||
os.remove(template.file_path)
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_admin, get_current_user, get_password_hash
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas import AdminUserCreate, UserResponse, UserUpdate
|
||||
from app.services.audit import get_client_ip, log_action
|
||||
from app.services.user_cleanup import cleanup_user_files
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
@@ -27,8 +28,9 @@ def list_users(
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
user_data: AdminUserCreate,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
current_user: Annotated[User, Depends(get_current_admin)],
|
||||
):
|
||||
from app.auth import get_user_by_email, get_user_by_username
|
||||
|
||||
@@ -45,6 +47,16 @@ def create_user(
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
log_action(
|
||||
db,
|
||||
"user.create",
|
||||
user=current_user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
details={"username": user.username, "email": user.email, "role": user.role.value},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
@@ -54,6 +66,7 @@ def create_user(
|
||||
def update_user(
|
||||
user_id: int,
|
||||
update: UserUpdate,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_admin)],
|
||||
):
|
||||
@@ -77,9 +90,11 @@ def update_user(
|
||||
if _count_admins(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate the last admin")
|
||||
|
||||
changes: dict = {}
|
||||
if update.email is not None and update.email != user.email:
|
||||
if get_user_by_email(db, update.email):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
changes["email"] = {"from": user.email, "to": update.email}
|
||||
user.email = update.email
|
||||
|
||||
if update.username is not None and update.username != user.username:
|
||||
@@ -87,15 +102,40 @@ def update_user(
|
||||
|
||||
if get_user_by_username(db, update.username):
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
changes["username"] = {"from": user.username, "to": update.username}
|
||||
user.username = update.username
|
||||
|
||||
if update.role is not None:
|
||||
if update.role is not None and update.role != user.role:
|
||||
changes["role"] = {"from": user.role.value, "to": update.role.value}
|
||||
user.role = update.role
|
||||
if update.is_active is not None:
|
||||
|
||||
if update.is_active is not None and update.is_active != user.is_active:
|
||||
user.is_active = update.is_active
|
||||
log_action(
|
||||
db,
|
||||
"user.activate" if update.is_active else "user.deactivate",
|
||||
user=current_user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
details={"target_username": user.username},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
|
||||
if update.password:
|
||||
changes["password"] = "changed"
|
||||
user.hashed_password = get_password_hash(update.password)
|
||||
|
||||
if changes:
|
||||
log_action(
|
||||
db,
|
||||
"user.update",
|
||||
user=current_user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
details={"target_username": user.username, "changes": changes},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
@@ -104,6 +144,7 @@ def update_user(
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_admin)],
|
||||
):
|
||||
@@ -117,6 +158,15 @@ def delete_user(
|
||||
if user.role == UserRole.admin and _count_admins(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
|
||||
|
||||
log_action(
|
||||
db,
|
||||
"user.delete",
|
||||
user=current_user,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
details={"username": user.username, "email": user.email},
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
cleanup_user_files(db, user)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
@@ -38,6 +38,20 @@ class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int | None
|
||||
username: str | None
|
||||
action: str
|
||||
resource_type: str | None
|
||||
resource_id: int | None
|
||||
details: dict[str, Any] | None = None
|
||||
ip_address: str | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
username: str = Field(min_length=3, max_length=100)
|
||||
|
||||
41
backend/app/services/audit.py
Normal file
41
backend/app/services/audit.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def get_client_ip(request: Request | None) -> str | None:
|
||||
if request is None:
|
||||
return None
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return None
|
||||
|
||||
|
||||
def log_action(
|
||||
db: Session,
|
||||
action: str,
|
||||
*,
|
||||
user: User | None = None,
|
||||
resource_type: str | None = None,
|
||||
resource_id: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> None:
|
||||
entry = AuditLog(
|
||||
user_id=user.id if user else None,
|
||||
username=user.username if user else None,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=json.dumps(details, ensure_ascii=False) if details else None,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.add(entry)
|
||||
Reference in New Issue
Block a user