feat(phase-4): Task 1 — audit log backfill in auth.py and documents.py (D-13)
- Add write_audit_log import to auth.py and documents.py - auth.py: login success (auth.login), login failure (auth.login_failed, no PII), logout (auth.logout), logout-all (auth.sign_out_all), change-password (auth.password_changed), TOTP enable (auth.totp_enrolled), TOTP disable (auth.totp_revoked), backup code used (auth.backup_code_used) - documents.py: upload confirm (document.uploaded, size+backend only), document delete (document.deleted, size only — no filename/extracted_text) - Add request: Request param to change_password, disable_totp, confirm_upload, delete_document
This commit is contained in:
@@ -33,6 +33,7 @@ from db.models import BackupCode, Quota, RefreshToken, User
|
|||||||
from deps.auth import get_current_user
|
from deps.auth import get_current_user
|
||||||
from deps.db import get_db
|
from deps.db import get_db
|
||||||
from services import auth as auth_service
|
from services import auth as auth_service
|
||||||
|
from services.audit import write_audit_log
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
@@ -226,8 +227,22 @@ async def login(
|
|||||||
result = await session.execute(select(User).where(User.email == str(body.email)))
|
result = await session.execute(select(User).where(User.email == str(body.email)))
|
||||||
user: Optional[User] = result.scalar_one_or_none()
|
user: Optional[User] = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# IP extraction for audit log (used in both success and failure paths)
|
||||||
|
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||||
|
|
||||||
# Verify password (anti-enumeration: same error regardless of whether user exists)
|
# Verify password (anti-enumeration: same error regardless of whether user exists)
|
||||||
if user is None or not auth_service.verify_password(body.password, user.password_hash):
|
if user is None or not auth_service.verify_password(body.password, user.password_hash):
|
||||||
|
# D-13: log login failure WITHOUT PII (no email, no password) — T-04-07-01
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="auth.login_failed",
|
||||||
|
user_id=None,
|
||||||
|
actor_id=None,
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=_ip,
|
||||||
|
metadata_=None,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect email or password",
|
detail="Incorrect email or password",
|
||||||
@@ -266,12 +281,33 @@ async def login(
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid or already used code",
|
detail="Invalid or already used code",
|
||||||
)
|
)
|
||||||
|
# D-13: backup code used event
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="auth.backup_code_used",
|
||||||
|
user_id=user.id,
|
||||||
|
actor_id=user.id,
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=_ip,
|
||||||
|
)
|
||||||
|
|
||||||
# Issue tokens
|
# Issue tokens
|
||||||
access_token = auth_service.create_access_token(str(user.id), user.role)
|
access_token = auth_service.create_access_token(str(user.id), user.role)
|
||||||
raw_refresh = await auth_service.create_refresh_token(session, user.id)
|
raw_refresh = await auth_service.create_refresh_token(session, user.id)
|
||||||
_set_refresh_cookie(response, raw_refresh)
|
_set_refresh_cookie(response, raw_refresh)
|
||||||
|
|
||||||
|
# D-13: login success event
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="auth.login",
|
||||||
|
user_id=user.id,
|
||||||
|
actor_id=user.id,
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=_ip,
|
||||||
|
metadata_={"totp_used": user.totp_enabled and body.totp_code is not None},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -350,7 +386,10 @@ async def logout(request: Request, response: Response, session: AsyncSession = D
|
|||||||
"""Revoke current refresh token and clear the cookie."""
|
"""Revoke current refresh token and clear the cookie."""
|
||||||
import hashlib as _hashlib
|
import hashlib as _hashlib
|
||||||
|
|
||||||
|
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||||
|
|
||||||
raw_token = request.cookies.get("refresh_token")
|
raw_token = request.cookies.get("refresh_token")
|
||||||
|
_logout_user_id = None
|
||||||
if raw_token:
|
if raw_token:
|
||||||
token_hash = _hashlib.sha256(raw_token.encode()).hexdigest()
|
token_hash = _hashlib.sha256(raw_token.encode()).hexdigest()
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -358,7 +397,17 @@ async def logout(request: Request, response: Response, session: AsyncSession = D
|
|||||||
)
|
)
|
||||||
row: Optional[RefreshToken] = result.scalar_one_or_none()
|
row: Optional[RefreshToken] = result.scalar_one_or_none()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
|
_logout_user_id = row.user_id
|
||||||
row.revoked = True
|
row.revoked = True
|
||||||
|
# D-13: logout event (written before commit, within same transaction)
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="auth.logout",
|
||||||
|
user_id=_logout_user_id,
|
||||||
|
actor_id=_logout_user_id,
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=_ip,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||||
@@ -375,7 +424,19 @@ async def logout_all(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Sign out of all devices: revoke all refresh tokens for current user."""
|
"""Sign out of all devices: revoke all refresh tokens for current user."""
|
||||||
|
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||||
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
|
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
|
||||||
|
# D-13: sign-out-all event
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="auth.sign_out_all",
|
||||||
|
user_id=current_user.id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=_ip,
|
||||||
|
metadata_={"sessions_revoked": count},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||||
return {"message": f"Signed out of {count} session(s)"}
|
return {"message": f"Signed out of {count} session(s)"}
|
||||||
|
|
||||||
@@ -410,6 +471,7 @@ async def get_my_quota(
|
|||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(
|
async def change_password(
|
||||||
|
request: Request,
|
||||||
body: ChangePasswordRequest,
|
body: ChangePasswordRequest,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -443,8 +505,18 @@ async def change_password(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update password
|
# Update password
|
||||||
|
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||||
user = await session.get(User, current_user.id)
|
user = await session.get(User, current_user.id)
|
||||||
user.password_hash = auth_service.hash_password(body.new_password)
|
user.password_hash = auth_service.hash_password(body.new_password)
|
||||||
|
# D-13: password changed event (flush within same transaction before commit)
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="auth.password_changed",
|
||||||
|
user_id=current_user.id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=_ip,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"message": "Password updated"}
|
return {"message": "Password updated"}
|
||||||
@@ -522,6 +594,18 @@ async def enable_totp(
|
|||||||
plain_codes = auth_service.generate_backup_codes(10)
|
plain_codes = auth_service.generate_backup_codes(10)
|
||||||
await auth_service.store_backup_codes(session, current_user.id, plain_codes)
|
await auth_service.store_backup_codes(session, current_user.id, plain_codes)
|
||||||
|
|
||||||
|
# D-13: TOTP enrolled event
|
||||||
|
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="auth.totp_enrolled",
|
||||||
|
user_id=current_user.id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=_ip,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
return {"backup_codes": plain_codes}
|
return {"backup_codes": plain_codes}
|
||||||
|
|
||||||
|
|
||||||
@@ -529,6 +613,7 @@ async def enable_totp(
|
|||||||
|
|
||||||
@router.delete("/totp")
|
@router.delete("/totp")
|
||||||
async def disable_totp(
|
async def disable_totp(
|
||||||
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -536,12 +621,23 @@ async def disable_totp(
|
|||||||
|
|
||||||
Clears totp_secret, sets totp_enabled=False, and deletes all backup codes.
|
Clears totp_secret, sets totp_enabled=False, and deletes all backup codes.
|
||||||
"""
|
"""
|
||||||
|
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||||
user = await session.get(User, current_user.id)
|
user = await session.get(User, current_user.id)
|
||||||
user.totp_enabled = False
|
user.totp_enabled = False
|
||||||
user.totp_secret = None
|
user.totp_secret = None
|
||||||
|
|
||||||
# Delete all backup codes for this user (including unused ones)
|
# Delete all backup codes for this user (including unused ones)
|
||||||
await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id))
|
await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id))
|
||||||
|
|
||||||
|
# D-13: TOTP revoked event
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="auth.totp_revoked",
|
||||||
|
user_id=current_user.id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
resource_id=None,
|
||||||
|
ip_address=_ip,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"message": "TOTP disabled"}
|
return {"message": "TOTP disabled"}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from db.models import Document, Quota, Share, User
|
|||||||
from deps.auth import get_regular_user
|
from deps.auth import get_regular_user
|
||||||
from deps.db import get_db
|
from deps.db import get_db
|
||||||
from services import classifier, storage
|
from services import classifier, storage
|
||||||
|
from services.audit import write_audit_log
|
||||||
from storage import get_storage_backend
|
from storage import get_storage_backend
|
||||||
from tasks.document_tasks import extract_and_classify
|
from tasks.document_tasks import extract_and_classify
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ async def request_upload_url(
|
|||||||
@router.post("/{doc_id}/confirm")
|
@router.post("/{doc_id}/confirm")
|
||||||
async def confirm_upload(
|
async def confirm_upload(
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_regular_user),
|
current_user: User = Depends(get_regular_user),
|
||||||
):
|
):
|
||||||
@@ -173,6 +175,17 @@ async def confirm_upload(
|
|||||||
used_bytes = row.used_bytes
|
used_bytes = row.used_bytes
|
||||||
|
|
||||||
doc.status = "uploaded"
|
doc.status = "uploaded"
|
||||||
|
# D-13: document uploaded event — size_bytes + storage_backend only, NO filename, NO extracted_text (T-04-07-02)
|
||||||
|
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="document.uploaded",
|
||||||
|
user_id=current_user.id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
resource_id=doc.id,
|
||||||
|
ip_address=_ip,
|
||||||
|
metadata_={"size_bytes": size, "storage_backend": "minio"},
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
extract_and_classify.delay(str(doc.id))
|
extract_and_classify.delay(str(doc.id))
|
||||||
|
|
||||||
@@ -340,6 +353,7 @@ async def get_document(
|
|||||||
@router.delete("/{doc_id}")
|
@router.delete("/{doc_id}")
|
||||||
async def delete_document(
|
async def delete_document(
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_regular_user),
|
current_user: User = Depends(get_regular_user),
|
||||||
):
|
):
|
||||||
@@ -360,9 +374,27 @@ async def delete_document(
|
|||||||
if doc is None or doc.user_id != current_user.id:
|
if doc is None or doc.user_id != current_user.id:
|
||||||
raise HTTPException(404, "Document not found")
|
raise HTTPException(404, "Document not found")
|
||||||
|
|
||||||
|
# Capture audit metadata before delete removes the row
|
||||||
|
_doc_size = doc.size_bytes
|
||||||
|
_doc_id = doc.id
|
||||||
|
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||||
|
|
||||||
ok = await storage.delete_document(session, doc_id)
|
ok = await storage.delete_document(session, doc_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "Document not found")
|
raise HTTPException(404, "Document not found")
|
||||||
|
|
||||||
|
# D-13: document deleted event — written AFTER successful delete, size_bytes only (T-04-07-02)
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="document.deleted",
|
||||||
|
user_id=current_user.id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
resource_id=_doc_id,
|
||||||
|
ip_address=_ip,
|
||||||
|
metadata_={"size_bytes": _doc_size},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user