Fabio Antonini Claude Sonnet 4.6 commited on
Commit
b59af0d
·
1 Parent(s): 4e50c56

feat: immutable audit log for forensic chain-of-custody

Browse files

Backend:
- AuditLog model (append-only, no UPDATE/DELETE) with action enum,
user snapshot, resource type/id, detail, ip_address
- log_event() helper — silently swallows errors so logging never
breaks the main request flow
- Integrated in all key endpoints: login, project create/delete,
document upload/delete, all analysis types, PDF download, clear analyses
- GET /audit/ (admin only) with filters by action and user email,
pagination (page/page_size)

Frontend:
- AdminPage with two tabs: Users and Activity Log
- Audit tab: filter by action and email, paginated table with
color-coded action badges, timestamp, user, resource, detail, IP
- auditApi in api.ts
- i18n keys for audit log (IT + EN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

backend/audit.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Audit log helper.
3
+
4
+ Usage:
5
+ from backend.audit import log_event
6
+ from backend.models.audit import AuditAction
7
+
8
+ await log_event(
9
+ db = db,
10
+ user = current_user,
11
+ action = AuditAction.analysis_run,
12
+ resource_type = "analysis",
13
+ resource_id = analysis.id,
14
+ detail = "pipeline",
15
+ ip_address = request.client.host if request.client else None,
16
+ )
17
+
18
+ The function simply inserts a row and flushes — the caller's db session
19
+ commits at the end of the request as usual.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+
26
+ from backend.models.audit import AuditAction, AuditLog
27
+ from backend.models.user import User
28
+
29
+
30
+ async def log_event(
31
+ db: AsyncSession,
32
+ user: User,
33
+ action: AuditAction,
34
+ resource_type: str | None = None,
35
+ resource_id: int | None = None,
36
+ detail: str | None = None,
37
+ ip_address: str | None = None,
38
+ ) -> None:
39
+ """Append one row to the audit_log table. Never raises — failures are silently swallowed
40
+ so that a logging error never breaks an otherwise successful operation."""
41
+ try:
42
+ entry = AuditLog(
43
+ user_id=user.id,
44
+ user_email=user.email,
45
+ action=action,
46
+ resource_type=resource_type,
47
+ resource_id=resource_id,
48
+ detail=detail,
49
+ ip_address=ip_address,
50
+ )
51
+ db.add(entry)
52
+ await db.flush()
53
+ except Exception:
54
+ pass # audit must never break the main flow
backend/main.py CHANGED
@@ -18,7 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware
18
 
19
  from backend.config import settings
20
  from backend.database import init_db
21
- from backend.routers import auth, users, projects, analysis, rag
22
 
23
 
24
  @asynccontextmanager
@@ -51,6 +51,7 @@ app.include_router(users.router)
51
  app.include_router(projects.router)
52
  app.include_router(analysis.router)
53
  app.include_router(rag.router)
 
54
 
55
 
56
  # ── Health check ──────────────────────────────────────────────────────────────
 
18
 
19
  from backend.config import settings
20
  from backend.database import init_db
21
+ from backend.routers import auth, users, projects, analysis, rag, audit
22
 
23
 
24
  @asynccontextmanager
 
51
  app.include_router(projects.router)
52
  app.include_router(analysis.router)
53
  app.include_router(rag.router)
54
+ app.include_router(audit.router)
55
 
56
 
57
  # ── Health check ──────────────────────────────────────────────────────────────
backend/models/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
  # Import all models so SQLAlchemy can resolve relationships regardless of import order.
2
  from backend.models.user import User, Organization, Role # noqa: F401
3
  from backend.models.project import Project, Document, Analysis, ProjectStatus, AnalysisType # noqa: F401
 
 
1
  # Import all models so SQLAlchemy can resolve relationships regardless of import order.
2
  from backend.models.user import User, Organization, Role # noqa: F401
3
  from backend.models.project import Project, Document, Analysis, ProjectStatus, AnalysisType # noqa: F401
4
+ from backend.models.audit import AuditLog, AuditAction # noqa: F401
backend/models/audit.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — AuditLog ORM model.
3
+
4
+ Append-only table: no UPDATE or DELETE is ever issued by application code.
5
+ Every sensitive operation is recorded here for forensic chain-of-custody.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import enum
11
+ from datetime import datetime
12
+
13
+ from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func
14
+ from sqlalchemy.orm import Mapped, mapped_column
15
+
16
+ from backend.database import Base
17
+
18
+
19
+ class AuditAction(str, enum.Enum):
20
+ login = "login"
21
+ project_create = "project_create"
22
+ project_delete = "project_delete"
23
+ document_upload = "document_upload"
24
+ document_delete = "document_delete"
25
+ analysis_run = "analysis_run"
26
+ analysis_clear = "analysis_clear"
27
+ pdf_download = "pdf_download"
28
+
29
+
30
+ class AuditLog(Base):
31
+ """Immutable audit trail. Never UPDATE or DELETE rows from this table."""
32
+
33
+ __tablename__ = "audit_log"
34
+
35
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
36
+
37
+ # UTC timestamp — server-generated, not client-supplied
38
+ timestamp: Mapped[datetime] = mapped_column(
39
+ DateTime(timezone=True), server_default=func.now(), index=True
40
+ )
41
+
42
+ # Who performed the action (snapshot: user may be deleted later)
43
+ user_id: Mapped[int | None] = mapped_column(
44
+ Integer,
45
+ ForeignKey("users.id", ondelete="SET NULL"),
46
+ nullable=True,
47
+ index=True,
48
+ )
49
+ user_email: Mapped[str] = mapped_column(String(256), nullable=False)
50
+
51
+ # What happened
52
+ action: Mapped[AuditAction] = mapped_column(Enum(AuditAction), nullable=False, index=True)
53
+
54
+ # On which resource (e.g. resource_type="project", resource_id=42)
55
+ resource_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
56
+ resource_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
57
+
58
+ # Optional extra context (e.g. analysis type, filename)
59
+ detail: Mapped[str | None] = mapped_column(Text, nullable=True)
60
+
61
+ # Network info
62
+ ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
backend/routers/analysis.py CHANGED
@@ -33,9 +33,11 @@ from pydantic import BaseModel
33
  from sqlalchemy import delete, select
34
  from sqlalchemy.ext.asyncio import AsyncSession
35
 
 
36
  from backend.auth.dependencies import get_current_user
37
  from backend.config import settings
38
  from backend.database import get_db
 
39
  from backend.models.project import Analysis, AnalysisType, Document, Project
40
  from backend.models.user import Role, User
41
  from backend.storage.minio_client import download_object, upload_fileobj
@@ -150,7 +152,10 @@ async def run_htr(
150
  from core.ocr import htr_transcribe
151
  text = htr_transcribe(image)
152
 
153
- return await _save_analysis(db, body.project_id, doc.id, AnalysisType.htr, text)
 
 
 
154
 
155
 
156
  @router.post("/signature-detection", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
@@ -166,9 +171,12 @@ async def run_signature_detection(
166
  from core.signature import detect_and_crop
167
  annotated, _, summary = detect_and_crop(image)
168
 
169
- return await _save_analysis(
170
  db, body.project_id, doc.id, AnalysisType.signature_detection, summary, annotated
171
  )
 
 
 
172
 
173
 
174
  @router.post("/signature-verification", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
@@ -190,9 +198,12 @@ async def run_signature_verification(
190
 
191
  report, _ = sig_verify(ref_image, None, query, settings.signet_weights)
192
 
193
- return await _save_analysis(
194
  db, body.project_id, doc.id, AnalysisType.signature_verification, report
195
  )
 
 
 
196
 
197
 
198
  @router.post("/ner", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
@@ -210,7 +221,10 @@ async def run_ner(
210
  text = htr_transcribe(image)
211
  _, ner_summary = ner_extract(text)
212
 
213
- return await _save_analysis(db, body.project_id, doc.id, AnalysisType.ner, ner_summary)
 
 
 
214
 
215
 
216
  @router.post("/writer", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
@@ -226,9 +240,12 @@ async def run_writer_identification(
226
  from core.writer import writer_identify
227
  report, _ = writer_identify(image, settings.writer_samples_dir)
228
 
229
- return await _save_analysis(
230
  db, body.project_id, doc.id, AnalysisType.writer_identification, report
231
  )
 
 
 
232
 
233
 
234
  @router.post("/graphology", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
@@ -244,9 +261,12 @@ async def run_graphology(
244
  from core.graphology import grapho_analyse
245
  report, annotated = grapho_analyse(image)
246
 
247
- return await _save_analysis(
248
  db, body.project_id, doc.id, AnalysisType.graphology, report, annotated
249
  )
 
 
 
250
 
251
 
252
  @router.post("/dating", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
@@ -264,11 +284,14 @@ async def run_dating(
264
  text = htr_transcribe(image)
265
  dates = extract_dates(text)
266
  if dates:
267
- result = "\n".join(f"- {raw} → {dt.strftime('%Y-%m-%d')}" for raw, dt in dates)
268
  else:
269
- result = "Nessuna data rilevata nel documento."
270
 
271
- return await _save_analysis(db, body.project_id, doc.id, AnalysisType.dating, result)
 
 
 
272
 
273
 
274
  @router.post("/pipeline", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
@@ -331,6 +354,8 @@ async def run_pipeline(
331
  analysis.result_text = analysis.result_text.replace("__img_sig__", f"/api/analysis/{analysis.id}/image/sig") if analysis.result_text else analysis.result_text
332
  await db.flush()
333
 
 
 
334
  return analysis
335
 
336
 
@@ -357,6 +382,8 @@ async def clear_analyses(
357
  ) -> None:
358
  await _check_project_access(project_id, db, current_user)
359
  await db.execute(delete(Analysis).where(Analysis.project_id == project_id))
 
 
360
  await db.commit()
361
 
362
 
@@ -414,6 +441,8 @@ async def get_analysis_pdf(
414
  if not analysis.result_text:
415
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nessun testo disponibile.")
416
 
 
 
417
  pdf_bytes = await anyio.to_thread.run_sync(lambda: _generate_pdf(analysis))
418
  return StreamingResponse(
419
  io.BytesIO(pdf_bytes),
 
33
  from sqlalchemy import delete, select
34
  from sqlalchemy.ext.asyncio import AsyncSession
35
 
36
+ from backend.audit import log_event
37
  from backend.auth.dependencies import get_current_user
38
  from backend.config import settings
39
  from backend.database import get_db
40
+ from backend.models.audit import AuditAction
41
  from backend.models.project import Analysis, AnalysisType, Document, Project
42
  from backend.models.user import Role, User
43
  from backend.storage.minio_client import download_object, upload_fileobj
 
152
  from core.ocr import htr_transcribe
153
  text = htr_transcribe(image)
154
 
155
+ result = await _save_analysis(db, body.project_id, doc.id, AnalysisType.htr, text)
156
+ await log_event(db, current_user, AuditAction.analysis_run,
157
+ resource_type="analysis", resource_id=result.id, detail="htr")
158
+ return result
159
 
160
 
161
  @router.post("/signature-detection", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
 
171
  from core.signature import detect_and_crop
172
  annotated, _, summary = detect_and_crop(image)
173
 
174
+ result = await _save_analysis(
175
  db, body.project_id, doc.id, AnalysisType.signature_detection, summary, annotated
176
  )
177
+ await log_event(db, current_user, AuditAction.analysis_run,
178
+ resource_type="analysis", resource_id=result.id, detail="signature_detection")
179
+ return result
180
 
181
 
182
  @router.post("/signature-verification", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
 
198
 
199
  report, _ = sig_verify(ref_image, None, query, settings.signet_weights)
200
 
201
+ result = await _save_analysis(
202
  db, body.project_id, doc.id, AnalysisType.signature_verification, report
203
  )
204
+ await log_event(db, current_user, AuditAction.analysis_run,
205
+ resource_type="analysis", resource_id=result.id, detail="signature_verification")
206
+ return result
207
 
208
 
209
  @router.post("/ner", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
 
221
  text = htr_transcribe(image)
222
  _, ner_summary = ner_extract(text)
223
 
224
+ result = await _save_analysis(db, body.project_id, doc.id, AnalysisType.ner, ner_summary)
225
+ await log_event(db, current_user, AuditAction.analysis_run,
226
+ resource_type="analysis", resource_id=result.id, detail="ner")
227
+ return result
228
 
229
 
230
  @router.post("/writer", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
 
240
  from core.writer import writer_identify
241
  report, _ = writer_identify(image, settings.writer_samples_dir)
242
 
243
+ result = await _save_analysis(
244
  db, body.project_id, doc.id, AnalysisType.writer_identification, report
245
  )
246
+ await log_event(db, current_user, AuditAction.analysis_run,
247
+ resource_type="analysis", resource_id=result.id, detail="writer_identification")
248
+ return result
249
 
250
 
251
  @router.post("/graphology", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
 
261
  from core.graphology import grapho_analyse
262
  report, annotated = grapho_analyse(image)
263
 
264
+ result = await _save_analysis(
265
  db, body.project_id, doc.id, AnalysisType.graphology, report, annotated
266
  )
267
+ await log_event(db, current_user, AuditAction.analysis_run,
268
+ resource_type="analysis", resource_id=result.id, detail="graphology")
269
+ return result
270
 
271
 
272
  @router.post("/dating", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
 
284
  text = htr_transcribe(image)
285
  dates = extract_dates(text)
286
  if dates:
287
+ dating_result = "\n".join(f"- {raw} → {dt.strftime('%Y-%m-%d')}" for raw, dt in dates)
288
  else:
289
+ dating_result = "Nessuna data rilevata nel documento."
290
 
291
+ saved = await _save_analysis(db, body.project_id, doc.id, AnalysisType.dating, dating_result)
292
+ await log_event(db, current_user, AuditAction.analysis_run,
293
+ resource_type="analysis", resource_id=saved.id, detail="dating")
294
+ return saved
295
 
296
 
297
  @router.post("/pipeline", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
 
354
  analysis.result_text = analysis.result_text.replace("__img_sig__", f"/api/analysis/{analysis.id}/image/sig") if analysis.result_text else analysis.result_text
355
  await db.flush()
356
 
357
+ await log_event(db, current_user, AuditAction.analysis_run,
358
+ resource_type="analysis", resource_id=analysis.id, detail="pipeline")
359
  return analysis
360
 
361
 
 
382
  ) -> None:
383
  await _check_project_access(project_id, db, current_user)
384
  await db.execute(delete(Analysis).where(Analysis.project_id == project_id))
385
+ await log_event(db, current_user, AuditAction.analysis_clear,
386
+ resource_type="project", resource_id=project_id)
387
  await db.commit()
388
 
389
 
 
441
  if not analysis.result_text:
442
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nessun testo disponibile.")
443
 
444
+ await log_event(db, current_user, AuditAction.pdf_download,
445
+ resource_type="analysis", resource_id=analysis_id)
446
  pdf_bytes = await anyio.to_thread.run_sync(lambda: _generate_pdf(analysis))
447
  return StreamingResponse(
448
  io.BytesIO(pdf_bytes),
backend/routers/audit.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GraphoLab Backend — Audit log router.
3
+
4
+ Endpoints:
5
+ GET /audit → paginated audit log (admin only)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
13
+ from pydantic import BaseModel
14
+ from sqlalchemy import select, func
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from backend.auth.dependencies import get_current_user
18
+ from backend.database import get_db
19
+ from backend.models.audit import AuditAction, AuditLog
20
+ from backend.models.user import Role, User
21
+
22
+ router = APIRouter(prefix="/audit", tags=["audit"])
23
+
24
+
25
+ class AuditLogOut(BaseModel):
26
+ id: int
27
+ timestamp: datetime
28
+ user_id: int | None
29
+ user_email: str
30
+ action: AuditAction
31
+ resource_type: str | None
32
+ resource_id: int | None
33
+ detail: str | None
34
+ ip_address: str | None
35
+
36
+ model_config = {"from_attributes": True}
37
+
38
+
39
+ class AuditPage(BaseModel):
40
+ total: int
41
+ items: list[AuditLogOut]
42
+
43
+
44
+ @router.get("/", response_model=AuditPage)
45
+ async def list_audit_log(
46
+ page: int = Query(1, ge=1),
47
+ page_size: int = Query(50, ge=1, le=200),
48
+ action: AuditAction | None = Query(None),
49
+ user_email: str | None = Query(None),
50
+ db: AsyncSession = Depends(get_db),
51
+ current_user: User = Depends(get_current_user),
52
+ ) -> AuditPage:
53
+ if current_user.role != Role.admin:
54
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Solo gli amministratori possono visualizzare il log.")
55
+
56
+ q = select(AuditLog)
57
+ if action:
58
+ q = q.where(AuditLog.action == action)
59
+ if user_email:
60
+ q = q.where(AuditLog.user_email.ilike(f"%{user_email}%"))
61
+
62
+ count_q = select(func.count()).select_from(q.subquery())
63
+ total = (await db.execute(count_q)).scalar_one()
64
+
65
+ q = q.order_by(AuditLog.timestamp.desc()).offset((page - 1) * page_size).limit(page_size)
66
+ rows = (await db.execute(q)).scalars().all()
67
+
68
+ return AuditPage(total=total, items=list(rows))
backend/routers/auth.py CHANGED
@@ -9,16 +9,18 @@ Endpoints:
9
 
10
  from __future__ import annotations
11
 
12
- from fastapi import APIRouter, Depends, HTTPException, status
13
  from fastapi.security import OAuth2PasswordRequestForm
14
  from jose import JWTError
15
  from pydantic import BaseModel
16
  from sqlalchemy import select
17
  from sqlalchemy.ext.asyncio import AsyncSession
18
 
 
19
  from backend.auth.jwt import TokenType, create_access_token, create_refresh_token, decode_token
20
  from backend.auth.password import verify_password
21
  from backend.database import get_db
 
22
  from backend.models.user import User
23
 
24
  router = APIRouter(prefix="/auth", tags=["auth"])
@@ -36,6 +38,7 @@ class RefreshRequest(BaseModel):
36
 
37
  @router.post("/login", response_model=TokenResponse)
38
  async def login(
 
39
  form: OAuth2PasswordRequestForm = Depends(),
40
  db: AsyncSession = Depends(get_db),
41
  ) -> TokenResponse:
@@ -54,6 +57,11 @@ async def login(
54
  detail="Account disabilitato. Contatta l'amministratore.",
55
  )
56
 
 
 
 
 
 
57
  return TokenResponse(
58
  access_token=create_access_token(user.id, user.role),
59
  refresh_token=create_refresh_token(user.id),
 
9
 
10
  from __future__ import annotations
11
 
12
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
13
  from fastapi.security import OAuth2PasswordRequestForm
14
  from jose import JWTError
15
  from pydantic import BaseModel
16
  from sqlalchemy import select
17
  from sqlalchemy.ext.asyncio import AsyncSession
18
 
19
+ from backend.audit import log_event
20
  from backend.auth.jwt import TokenType, create_access_token, create_refresh_token, decode_token
21
  from backend.auth.password import verify_password
22
  from backend.database import get_db
23
+ from backend.models.audit import AuditAction
24
  from backend.models.user import User
25
 
26
  router = APIRouter(prefix="/auth", tags=["auth"])
 
38
 
39
  @router.post("/login", response_model=TokenResponse)
40
  async def login(
41
+ request: Request,
42
  form: OAuth2PasswordRequestForm = Depends(),
43
  db: AsyncSession = Depends(get_db),
44
  ) -> TokenResponse:
 
57
  detail="Account disabilitato. Contatta l'amministratore.",
58
  )
59
 
60
+ await log_event(
61
+ db, user, AuditAction.login,
62
+ ip_address=request.client.host if request.client else None,
63
+ )
64
+
65
  return TokenResponse(
66
  access_token=create_access_token(user.id, user.role),
67
  refresh_token=create_refresh_token(user.id),
backend/routers/projects.py CHANGED
@@ -22,8 +22,10 @@ from sqlalchemy import select
22
  from sqlalchemy.ext.asyncio import AsyncSession
23
  from sqlalchemy.orm import selectinload
24
 
 
25
  from backend.auth.dependencies import get_current_user
26
  from backend.database import get_db
 
27
  from backend.models.project import Document, Project, ProjectStatus
28
  from backend.models.user import Role, User
29
  from backend.storage.minio_client import delete_object, upload_fileobj
@@ -122,6 +124,8 @@ async def create_project(
122
  )
123
  db.add(project)
124
  await db.flush()
 
 
125
  project.document_count = 0
126
  return project
127
 
@@ -163,6 +167,8 @@ async def delete_project(
163
  current_user: User = Depends(get_current_user),
164
  ) -> None:
165
  project = await _get_project_or_404(project_id, db, current_user)
 
 
166
  # Remove files from MinIO
167
  for doc in project.documents:
168
  await delete_object(doc.storage_key)
@@ -195,6 +201,8 @@ async def upload_document(
195
  )
196
  db.add(doc)
197
  await db.flush()
 
 
198
  return doc
199
 
200
 
@@ -222,5 +230,7 @@ async def delete_document(
222
  doc = result.scalar_one_or_none()
223
  if doc is None:
224
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento non trovato.")
 
 
225
  await delete_object(doc.storage_key)
226
  await db.delete(doc)
 
22
  from sqlalchemy.ext.asyncio import AsyncSession
23
  from sqlalchemy.orm import selectinload
24
 
25
+ from backend.audit import log_event
26
  from backend.auth.dependencies import get_current_user
27
  from backend.database import get_db
28
+ from backend.models.audit import AuditAction
29
  from backend.models.project import Document, Project, ProjectStatus
30
  from backend.models.user import Role, User
31
  from backend.storage.minio_client import delete_object, upload_fileobj
 
124
  )
125
  db.add(project)
126
  await db.flush()
127
+ await log_event(db, current_user, AuditAction.project_create,
128
+ resource_type="project", resource_id=project.id, detail=body.title)
129
  project.document_count = 0
130
  return project
131
 
 
167
  current_user: User = Depends(get_current_user),
168
  ) -> None:
169
  project = await _get_project_or_404(project_id, db, current_user)
170
+ await log_event(db, current_user, AuditAction.project_delete,
171
+ resource_type="project", resource_id=project_id, detail=project.title)
172
  # Remove files from MinIO
173
  for doc in project.documents:
174
  await delete_object(doc.storage_key)
 
201
  )
202
  db.add(doc)
203
  await db.flush()
204
+ await log_event(db, current_user, AuditAction.document_upload,
205
+ resource_type="document", resource_id=doc.id, detail=file.filename)
206
  return doc
207
 
208
 
 
230
  doc = result.scalar_one_or_none()
231
  if doc is None:
232
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento non trovato.")
233
+ await log_event(db, current_user, AuditAction.document_delete,
234
+ resource_type="document", resource_id=doc_id, detail=doc.filename)
235
  await delete_object(doc.storage_key)
236
  await db.delete(doc)
frontend/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import LoginPage from "@/pages/LoginPage"
4
  import ProjectsPage from "@/pages/ProjectsPage"
5
  import ProjectDetailPage from "@/pages/ProjectDetailPage"
6
  import RagPage from "@/pages/RagPage"
 
7
 
8
  export default function App() {
9
  return (
@@ -14,6 +15,7 @@ export default function App() {
14
  <Route path="/projects" element={<ProjectsPage />} />
15
  <Route path="/projects/:id" element={<ProjectDetailPage />} />
16
  <Route path="/rag" element={<RagPage />} />
 
17
  </Route>
18
  <Route path="*" element={<Navigate to="/projects" replace />} />
19
  </Routes>
 
4
  import ProjectsPage from "@/pages/ProjectsPage"
5
  import ProjectDetailPage from "@/pages/ProjectDetailPage"
6
  import RagPage from "@/pages/RagPage"
7
+ import AdminPage from "@/pages/AdminPage"
8
 
9
  export default function App() {
10
  return (
 
15
  <Route path="/projects" element={<ProjectsPage />} />
16
  <Route path="/projects/:id" element={<ProjectDetailPage />} />
17
  <Route path="/rag" element={<RagPage />} />
18
+ <Route path="/admin/*" element={<AdminPage />} />
19
  </Route>
20
  <Route path="*" element={<Navigate to="/projects" replace />} />
21
  </Routes>
frontend/src/i18n/en.json CHANGED
@@ -81,6 +81,7 @@
81
  },
82
  "admin": {
83
  "users": "Users",
 
84
  "new_user": "New user",
85
  "email": "Email",
86
  "full_name": "Full name",
@@ -92,7 +93,30 @@
92
  "admin": "Administrator",
93
  "examiner": "Examiner",
94
  "viewer": "Read-only"
95
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  },
97
  "common": {
98
  "loading": "Loading…",
 
81
  },
82
  "admin": {
83
  "users": "Users",
84
+ "audit": "Activity log",
85
  "new_user": "New user",
86
  "email": "Email",
87
  "full_name": "Full name",
 
93
  "admin": "Administrator",
94
  "examiner": "Examiner",
95
  "viewer": "Read-only"
96
+ },
97
+ "audit_timestamp": "Date/Time",
98
+ "audit_user": "User",
99
+ "audit_action": "Action",
100
+ "audit_resource": "Resource",
101
+ "audit_detail": "Detail",
102
+ "audit_ip": "IP",
103
+ "audit_actions": {
104
+ "login": "Login",
105
+ "project_create": "Case created",
106
+ "project_delete": "Case deleted",
107
+ "document_upload": "Document uploaded",
108
+ "document_delete": "Document deleted",
109
+ "analysis_run": "Analysis run",
110
+ "analysis_clear": "Analyses cleared",
111
+ "pdf_download": "PDF downloaded"
112
+ },
113
+ "audit_empty": "No activity recorded.",
114
+ "audit_filter_action": "Filter by action",
115
+ "audit_filter_user": "Filter by email",
116
+ "audit_all_actions": "All actions",
117
+ "audit_prev": "Previous",
118
+ "audit_next": "Next",
119
+ "audit_page": "Page {{page}} — {{total}} events"
120
  },
121
  "common": {
122
  "loading": "Loading…",
frontend/src/i18n/it.json CHANGED
@@ -81,6 +81,7 @@
81
  },
82
  "admin": {
83
  "users": "Utenti",
 
84
  "new_user": "Nuovo utente",
85
  "email": "Email",
86
  "full_name": "Nome completo",
@@ -92,7 +93,30 @@
92
  "admin": "Amministratore",
93
  "examiner": "Perito",
94
  "viewer": "Sola lettura"
95
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  },
97
  "common": {
98
  "loading": "Caricamento…",
 
81
  },
82
  "admin": {
83
  "users": "Utenti",
84
+ "audit": "Registro attività",
85
  "new_user": "Nuovo utente",
86
  "email": "Email",
87
  "full_name": "Nome completo",
 
93
  "admin": "Amministratore",
94
  "examiner": "Perito",
95
  "viewer": "Sola lettura"
96
+ },
97
+ "audit_timestamp": "Data/Ora",
98
+ "audit_user": "Utente",
99
+ "audit_action": "Azione",
100
+ "audit_resource": "Risorsa",
101
+ "audit_detail": "Dettaglio",
102
+ "audit_ip": "IP",
103
+ "audit_actions": {
104
+ "login": "Login",
105
+ "project_create": "Perizia creata",
106
+ "project_delete": "Perizia eliminata",
107
+ "document_upload": "Documento caricato",
108
+ "document_delete": "Documento eliminato",
109
+ "analysis_run": "Analisi eseguita",
110
+ "analysis_clear": "Analisi cancellate",
111
+ "pdf_download": "PDF scaricato"
112
+ },
113
+ "audit_empty": "Nessuna attività registrata.",
114
+ "audit_filter_action": "Filtra per azione",
115
+ "audit_filter_user": "Filtra per email",
116
+ "audit_all_actions": "Tutte le azioni",
117
+ "audit_prev": "Precedente",
118
+ "audit_next": "Successivo",
119
+ "audit_page": "Pagina {{page}} — {{total}} eventi"
120
  },
121
  "common": {
122
  "loading": "Caricamento…",
frontend/src/lib/api.ts CHANGED
@@ -161,6 +161,33 @@ export const analysisApi = {
161
  imageUrl: (analysisId: number) => `/api/analysis/${analysisId}/image`,
162
  }
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  // RAG
165
  export const ragApi = {
166
  status: () => api.get<{ ollama_reachable: boolean; models: string[] }>("/rag/status"),
 
161
  imageUrl: (analysisId: number) => `/api/analysis/${analysisId}/image`,
162
  }
163
 
164
+ // Audit
165
+ export interface AuditLogEntry {
166
+ id: number
167
+ timestamp: string
168
+ user_id: number | null
169
+ user_email: string
170
+ action: string
171
+ resource_type: string | null
172
+ resource_id: number | null
173
+ detail: string | null
174
+ ip_address: string | null
175
+ }
176
+
177
+ export interface AuditPage {
178
+ total: number
179
+ items: AuditLogEntry[]
180
+ }
181
+
182
+ export const auditApi = {
183
+ list: (page = 1, pageSize = 50, action?: string, userEmail?: string) => {
184
+ const params: Record<string, string | number> = { page, page_size: pageSize }
185
+ if (action) params.action = action
186
+ if (userEmail) params.user_email = userEmail
187
+ return api.get<AuditPage>("/audit/", { params })
188
+ },
189
+ }
190
+
191
  // RAG
192
  export const ragApi = {
193
  status: () => api.get<{ ollama_reachable: boolean; models: string[] }>("/rag/status"),
frontend/src/pages/AdminPage.tsx ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react"
2
+ import { useTranslation } from "react-i18next"
3
+ import { Users, ClipboardList, Plus, Trash2, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"
4
+ import { Button } from "@/components/ui/button"
5
+ import { Badge } from "@/components/ui/badge"
6
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
7
+ import { Input } from "@/components/ui/input"
8
+ import { api, usersApi, auditApi, type User, type AuditLogEntry } from "@/lib/api"
9
+
10
+ const AUDIT_ACTIONS = [
11
+ "login",
12
+ "project_create",
13
+ "project_delete",
14
+ "document_upload",
15
+ "document_delete",
16
+ "analysis_run",
17
+ "analysis_clear",
18
+ "pdf_download",
19
+ ]
20
+
21
+ const ACTION_COLORS: Record<string, string> = {
22
+ login: "bg-blue-100 text-blue-800",
23
+ project_create: "bg-green-100 text-green-800",
24
+ project_delete: "bg-red-100 text-red-800",
25
+ document_upload: "bg-green-100 text-green-800",
26
+ document_delete: "bg-red-100 text-red-800",
27
+ analysis_run: "bg-purple-100 text-purple-800",
28
+ analysis_clear: "bg-orange-100 text-orange-800",
29
+ pdf_download: "bg-gray-100 text-gray-800",
30
+ }
31
+
32
+ export default function AdminPage() {
33
+ const { t } = useTranslation()
34
+ const [tab, setTab] = useState<"users" | "audit">("users")
35
+
36
+ // ── Users state ──────────────────────────────────────────────────────────────
37
+ const [users, setUsers] = useState<User[]>([])
38
+ const [newEmail, setNewEmail] = useState("")
39
+ const [newName, setNewName] = useState("")
40
+ const [newPassword, setNewPassword] = useState("")
41
+ const [newRole, setNewRole] = useState<"admin" | "examiner" | "viewer">("examiner")
42
+ const [userError, setUserError] = useState("")
43
+
44
+ async function loadUsers() {
45
+ const { data } = await usersApi.list()
46
+ setUsers(data)
47
+ }
48
+
49
+ async function handleCreateUser(e: React.FormEvent) {
50
+ e.preventDefault()
51
+ setUserError("")
52
+ try {
53
+ const { data } = await usersApi.create({ email: newEmail, full_name: newName, password: newPassword, role: newRole })
54
+ setUsers((u) => [...u, data])
55
+ setNewEmail(""); setNewName(""); setNewPassword(""); setNewRole("examiner")
56
+ } catch (err: any) {
57
+ setUserError(err?.response?.data?.detail ?? t("common.error"))
58
+ }
59
+ }
60
+
61
+ async function handleDeactivate(userId: number) {
62
+ if (!confirm(t("common.confirm"))) return
63
+ await usersApi.deactivate(userId)
64
+ setUsers((u) => u.map((x) => x.id === userId ? { ...x, is_active: false } : x))
65
+ }
66
+
67
+ // ── Audit state ──────────────────────────────────────────────────────────────
68
+ const [auditItems, setAuditItems] = useState<AuditLogEntry[]>([])
69
+ const [auditTotal, setAuditTotal] = useState(0)
70
+ const [auditPage, setAuditPage] = useState(1)
71
+ const [filterAction, setFilterAction] = useState("")
72
+ const [filterEmail, setFilterEmail] = useState("")
73
+ const PAGE_SIZE = 50
74
+
75
+ async function loadAudit(page = auditPage) {
76
+ const { data } = await auditApi.list(page, PAGE_SIZE, filterAction || undefined, filterEmail || undefined)
77
+ setAuditItems(data.items)
78
+ setAuditTotal(data.total)
79
+ }
80
+
81
+ useEffect(() => { loadUsers() }, [])
82
+ useEffect(() => { if (tab === "audit") loadAudit(1) }, [tab])
83
+
84
+ function handleAuditFilter(e: React.FormEvent) {
85
+ e.preventDefault()
86
+ setAuditPage(1)
87
+ loadAudit(1)
88
+ }
89
+
90
+ function changePage(delta: number) {
91
+ const next = auditPage + delta
92
+ setAuditPage(next)
93
+ loadAudit(next)
94
+ }
95
+
96
+ const totalPages = Math.max(1, Math.ceil(auditTotal / PAGE_SIZE))
97
+
98
+ return (
99
+ <div className="p-6 max-w-5xl mx-auto space-y-4">
100
+ <h1 className="text-xl font-semibold">{t("nav.admin")}</h1>
101
+
102
+ {/* Tab bar */}
103
+ <div className="flex gap-1 border-b pb-0">
104
+ <button
105
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === "users" ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
106
+ onClick={() => setTab("users")}
107
+ >
108
+ <Users className="h-4 w-4" />
109
+ {t("admin.users")}
110
+ </button>
111
+ <button
112
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === "audit" ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
113
+ onClick={() => setTab("audit")}
114
+ >
115
+ <ClipboardList className="h-4 w-4" />
116
+ {t("admin.audit")}
117
+ </button>
118
+ </div>
119
+
120
+ {/* ── Users tab ─────────────────────────────────────────────────────────── */}
121
+ {tab === "users" && (
122
+ <div className="space-y-4">
123
+ {/* Create user form */}
124
+ <Card>
125
+ <CardHeader className="pb-2">
126
+ <CardTitle className="text-sm flex items-center gap-2">
127
+ <Plus className="h-4 w-4" />
128
+ {t("admin.new_user")}
129
+ </CardTitle>
130
+ </CardHeader>
131
+ <CardContent>
132
+ <form onSubmit={handleCreateUser} className="flex flex-wrap gap-2 items-end">
133
+ <div className="flex flex-col gap-1">
134
+ <label className="text-xs text-muted-foreground">{t("admin.email")}</label>
135
+ <Input className="h-8 text-sm w-48" type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} required />
136
+ </div>
137
+ <div className="flex flex-col gap-1">
138
+ <label className="text-xs text-muted-foreground">{t("admin.full_name")}</label>
139
+ <Input className="h-8 text-sm w-40" value={newName} onChange={(e) => setNewName(e.target.value)} required />
140
+ </div>
141
+ <div className="flex flex-col gap-1">
142
+ <label className="text-xs text-muted-foreground">Password</label>
143
+ <Input className="h-8 text-sm w-32" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
144
+ </div>
145
+ <div className="flex flex-col gap-1">
146
+ <label className="text-xs text-muted-foreground">{t("admin.role")}</label>
147
+ <select className="h-8 text-sm border rounded-md px-2" value={newRole} onChange={(e) => setNewRole(e.target.value as any)}>
148
+ <option value="examiner">{t("admin.roles.examiner")}</option>
149
+ <option value="viewer">{t("admin.roles.viewer")}</option>
150
+ <option value="admin">{t("admin.roles.admin")}</option>
151
+ </select>
152
+ </div>
153
+ <Button type="submit" size="sm" className="h-8">{t("admin.new_user")}</Button>
154
+ </form>
155
+ {userError && <p className="text-xs text-destructive mt-2">{userError}</p>}
156
+ </CardContent>
157
+ </Card>
158
+
159
+ {/* Users table */}
160
+ <Card>
161
+ <CardContent className="pt-4">
162
+ <table className="w-full text-sm">
163
+ <thead>
164
+ <tr className="border-b text-xs text-muted-foreground">
165
+ <th className="text-left pb-2 font-medium">{t("admin.full_name")}</th>
166
+ <th className="text-left pb-2 font-medium">{t("admin.email")}</th>
167
+ <th className="text-left pb-2 font-medium">{t("admin.role")}</th>
168
+ <th className="text-left pb-2 font-medium">{t("admin.status")}</th>
169
+ <th />
170
+ </tr>
171
+ </thead>
172
+ <tbody>
173
+ {users.map((u) => (
174
+ <tr key={u.id} className="border-b last:border-0">
175
+ <td className="py-2">{u.full_name}</td>
176
+ <td className="py-2 text-muted-foreground">{u.email}</td>
177
+ <td className="py-2">
178
+ <Badge variant="outline" className="text-xs">{t(`admin.roles.${u.role}`)}</Badge>
179
+ </td>
180
+ <td className="py-2">
181
+ <Badge variant={u.is_active ? "default" : "secondary"} className="text-xs">
182
+ {u.is_active ? t("admin.active") : t("admin.inactive")}
183
+ </Badge>
184
+ </td>
185
+ <td className="py-2 text-right">
186
+ {u.is_active && (
187
+ <Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive"
188
+ onClick={() => handleDeactivate(u.id)}>
189
+ <Trash2 className="h-3.5 w-3.5" />
190
+ </Button>
191
+ )}
192
+ </td>
193
+ </tr>
194
+ ))}
195
+ </tbody>
196
+ </table>
197
+ </CardContent>
198
+ </Card>
199
+ </div>
200
+ )}
201
+
202
+ {/* ── Audit tab ─────────────────────────────────────────────────────────── */}
203
+ {tab === "audit" && (
204
+ <div className="space-y-3">
205
+ {/* Filters */}
206
+ <form onSubmit={handleAuditFilter} className="flex flex-wrap gap-2 items-end">
207
+ <div className="flex flex-col gap-1">
208
+ <label className="text-xs text-muted-foreground">{t("admin.audit_filter_action")}</label>
209
+ <select className="h-8 text-sm border rounded-md px-2 w-44"
210
+ value={filterAction} onChange={(e) => setFilterAction(e.target.value)}>
211
+ <option value="">{t("admin.audit_all_actions")}</option>
212
+ {AUDIT_ACTIONS.map((a) => (
213
+ <option key={a} value={a}>{t(`admin.audit_actions.${a}`)}</option>
214
+ ))}
215
+ </select>
216
+ </div>
217
+ <div className="flex flex-col gap-1">
218
+ <label className="text-xs text-muted-foreground">{t("admin.audit_filter_user")}</label>
219
+ <Input className="h-8 text-sm w-48" placeholder="email…"
220
+ value={filterEmail} onChange={(e) => setFilterEmail(e.target.value)} />
221
+ </div>
222
+ <Button type="submit" size="sm" className="h-8 gap-1">
223
+ <RefreshCw className="h-3.5 w-3.5" />
224
+ {t("common.confirm")}
225
+ </Button>
226
+ </form>
227
+
228
+ {/* Summary */}
229
+ {auditTotal > 0 && (
230
+ <p className="text-xs text-muted-foreground">
231
+ {t("admin.audit_page", { page: auditPage, total: auditTotal })}
232
+ </p>
233
+ )}
234
+
235
+ {/* Table */}
236
+ <Card>
237
+ <CardContent className="pt-4 overflow-x-auto">
238
+ {auditItems.length === 0 ? (
239
+ <p className="text-sm text-muted-foreground">{t("admin.audit_empty")}</p>
240
+ ) : (
241
+ <table className="w-full text-xs">
242
+ <thead>
243
+ <tr className="border-b text-muted-foreground">
244
+ <th className="text-left pb-2 font-medium">{t("admin.audit_timestamp")}</th>
245
+ <th className="text-left pb-2 font-medium">{t("admin.audit_user")}</th>
246
+ <th className="text-left pb-2 font-medium">{t("admin.audit_action")}</th>
247
+ <th className="text-left pb-2 font-medium">{t("admin.audit_resource")}</th>
248
+ <th className="text-left pb-2 font-medium">{t("admin.audit_detail")}</th>
249
+ <th className="text-left pb-2 font-medium">{t("admin.audit_ip")}</th>
250
+ </tr>
251
+ </thead>
252
+ <tbody>
253
+ {auditItems.map((entry) => (
254
+ <tr key={entry.id} className="border-b last:border-0">
255
+ <td className="py-1.5 pr-3 whitespace-nowrap text-muted-foreground">
256
+ {new Date(entry.timestamp).toLocaleString()}
257
+ </td>
258
+ <td className="py-1.5 pr-3">{entry.user_email}</td>
259
+ <td className="py-1.5 pr-3">
260
+ <span className={`px-1.5 py-0.5 rounded text-xs font-medium ${ACTION_COLORS[entry.action] ?? "bg-gray-100 text-gray-800"}`}>
261
+ {t(`admin.audit_actions.${entry.action}`, { defaultValue: entry.action })}
262
+ </span>
263
+ </td>
264
+ <td className="py-1.5 pr-3 text-muted-foreground">
265
+ {entry.resource_type ? `${entry.resource_type}/${entry.resource_id}` : "—"}
266
+ </td>
267
+ <td className="py-1.5 pr-3">{entry.detail ?? "—"}</td>
268
+ <td className="py-1.5 text-muted-foreground">{entry.ip_address ?? "—"}</td>
269
+ </tr>
270
+ ))}
271
+ </tbody>
272
+ </table>
273
+ )}
274
+ </CardContent>
275
+ </Card>
276
+
277
+ {/* Pagination */}
278
+ {totalPages > 1 && (
279
+ <div className="flex items-center gap-2 justify-end">
280
+ <Button variant="outline" size="sm" className="h-7 gap-1"
281
+ disabled={auditPage <= 1} onClick={() => changePage(-1)}>
282
+ <ChevronLeft className="h-3.5 w-3.5" />
283
+ {t("admin.audit_prev")}
284
+ </Button>
285
+ <span className="text-xs text-muted-foreground">{auditPage} / {totalPages}</span>
286
+ <Button variant="outline" size="sm" className="h-7 gap-1"
287
+ disabled={auditPage >= totalPages} onClick={() => changePage(1)}>
288
+ {t("admin.audit_next")}
289
+ <ChevronRight className="h-3.5 w-3.5" />
290
+ </Button>
291
+ </div>
292
+ )}
293
+ </div>
294
+ )}
295
+ </div>
296
+ )
297
+ }