From da4608c937927f10ec2bd6e3fb959586fcc7cf90 Mon Sep 17 00:00:00 2001 From: bonamin Date: Fri, 17 Apr 2026 15:44:17 +0300 Subject: [PATCH] Phase 4 of Migration --- backend/audit/__init__.py | 0 backend/audit/router.py | 74 ++++++++++++++++++++++++++++ backend/auth/router.py | 21 +++++++- backend/main.py | 2 + backend/shared/audit.py | 83 ++++++++++++++++++++++++++++++++ backend/staff/router.py | 8 +++ backend/staff/service.py | 74 ++++++++++++++++++++++++++-- strategies/DATABASE_MIGRATION.md | 2 +- 8 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 backend/audit/__init__.py create mode 100644 backend/audit/router.py create mode 100644 backend/shared/audit.py diff --git a/backend/audit/__init__.py b/backend/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/audit/router.py b/backend/audit/router.py new file mode 100644 index 0000000..ad91a79 --- /dev/null +++ b/backend/audit/router.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from database.postgres import get_pg_session +from shared.orm import AuditLog +from auth.dependencies import require_admin_or_above +from auth.models import TokenPayload + +router = APIRouter(prefix="/api/audit-log", tags=["audit-log"]) + +_MAX_LIMIT = 200 +_DEFAULT_LIMIT = 50 + + +@router.get("") +async def list_audit_log( + actor_id: Optional[str] = Query(None), + entity_type: Optional[str] = Query(None), + entity_id: Optional[str] = Query(None), + action: Optional[str] = Query(None), + from_date: Optional[datetime] = Query(None), + to_date: Optional[datetime] = Query(None), + limit: int = Query(_DEFAULT_LIMIT, ge=1, le=_MAX_LIMIT), + offset: int = Query(0, ge=0), + _user: TokenPayload = Depends(require_admin_or_above), + db: AsyncSession = Depends(get_pg_session), +): + filters = [] + if actor_id: + filters.append(AuditLog.actor_id == actor_id) + if entity_type: + filters.append(AuditLog.entity_type == entity_type) + if entity_id: + filters.append(AuditLog.entity_id == entity_id) + if action: + filters.append(AuditLog.action == action) + if from_date: + filters.append(AuditLog.occurred_at >= from_date) + if to_date: + filters.append(AuditLog.occurred_at <= to_date) + + stmt = ( + select(AuditLog) + .where(and_(*filters) if filters else True) + .order_by(AuditLog.occurred_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(stmt) + rows = result.scalars().all() + + return { + "entries": [ + { + "id": r.id, + "occurred_at": r.occurred_at.isoformat(), + "actor_id": r.actor_id, + "actor_name": r.actor_name, + "action": r.action, + "entity_type": r.entity_type, + "entity_id": r.entity_id, + "entity_label": r.entity_label, + "changes": r.changes, + "meta": r.meta, + } + for r in rows + ], + "limit": limit, + "offset": offset, + } diff --git a/backend/auth/router.py b/backend/auth/router.py index 4018abe..9a42529 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -6,6 +6,7 @@ from database.postgres import get_pg_session from staff.orm import Staff from auth.models import LoginRequest, TokenResponse from auth.utils import verify_password, create_access_token +from shared.audit import log_action from shared.exceptions import AuthenticationError router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -21,7 +22,11 @@ _ROLE_MAP = { @router.post("/login", response_model=TokenResponse) -async def login(body: LoginRequest, db: AsyncSession = Depends(get_pg_session)): +async def login( + body: LoginRequest, + request: Request, + db: AsyncSession = Depends(get_pg_session), +): result = await db.execute( select(Staff).where(Staff.email == body.email).limit(1) ) @@ -49,6 +54,18 @@ async def login(body: LoginRequest, db: AsyncSession = Depends(get_pg_session)): if role in ("editor", "user"): permissions = staff.permissions + await log_action( + db, + actor_id=staff.id, + actor_name=staff.name, + action="LOGIN", + entity_type="staff", + entity_id=staff.id, + entity_label=staff.email, + meta={"ip": request.client.host if request.client else None}, + ) + await db.commit() + return TokenResponse( access_token=token, role=role, diff --git a/backend/main.py b/backend/main.py index 028b25f..c55fde0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -27,6 +27,7 @@ from crm.quotations_router import router as crm_quotations_router from public.router import router as public_router from notes.router import router as notes_router from tickets.router import router as tickets_router +from audit.router import router as audit_router from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive from crm.mail_accounts import get_mail_accounts from mqtt.client import mqtt_manager @@ -74,6 +75,7 @@ app.include_router(crm_quotations_router) app.include_router(public_router) app.include_router(notes_router) app.include_router(tickets_router) +app.include_router(audit_router) async def nextcloud_keepalive_loop(): diff --git a/backend/shared/audit.py b/backend/shared/audit.py new file mode 100644 index 0000000..8ab1b8e --- /dev/null +++ b/backend/shared/audit.py @@ -0,0 +1,83 @@ +""" +Audit log utility — all services call log_action() to record staff events. + +Usage: + from shared.audit import log_action + + await log_action( + db, actor_id, actor_name, + action="CREATE", entity_type="customer", entity_id=cust_id, + entity_label="Church of St. George", + ) + + await log_action( + db, actor_id, actor_name, + action="UPDATE", entity_type="order", entity_id=order_id, + entity_label="ORD-0042", + changes={"status": {"old": "negotiating", "new": "confirmed"}}, + ) + +Never raises — a logging failure must never break the primary operation. +The call is fire-and-forget safe: wrap in try/except internally. +""" + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.orm import AuditLog + + +async def log_action( + db: AsyncSession, + actor_id: str, + actor_name: str, + action: str, + entity_type: str, + entity_id: str, + entity_label: str | None = None, + changes: dict[str, Any] | None = None, + meta: dict[str, Any] | None = None, +) -> None: + """ + Insert one row into audit_log. Never raises — failures are silently swallowed + so a logging error never disrupts the primary request. + """ + try: + entry = AuditLog( + occurred_at=datetime.now(timezone.utc), + actor_id=actor_id, + actor_name=actor_name, + action=action, + entity_type=entity_type, + entity_id=entity_id, + entity_label=entity_label, + changes=changes, + meta=meta, + ) + db.add(entry) + # Flush without committing — caller's transaction commits it atomically. + # If the caller hasn't started a transaction, flush still works; the + # session will auto-commit on the next explicit commit call. + await db.flush() + except Exception: + pass + + +def diff(old: dict, new: dict) -> dict[str, dict]: + """ + Build a changes dict from two flat dicts. + Only includes keys whose values actually changed. + Skip internal/unloggable keys (hashed_password, updated_at). + + Usage: + changes = diff(old_record, new_record) + await log_action(..., changes=changes or None) + """ + _SKIP = {"hashed_password", "updated_at", "firestore_id"} + return { + k: {"old": old.get(k), "new": new.get(k)} + for k in new + if k not in _SKIP and old.get(k) != new.get(k) + } diff --git a/backend/staff/router.py b/backend/staff/router.py index 797a099..2b4c68b 100644 --- a/backend/staff/router.py +++ b/backend/staff/router.py @@ -69,6 +69,8 @@ async def create_staff( db, data=body.model_dump(), current_user_role=current_user.role, + actor_id=current_user.sub, + actor_name=current_user.name, ) @@ -85,6 +87,8 @@ async def update_staff( data=body.model_dump(exclude_unset=True), current_user_role=current_user.role, current_user_id=current_user.sub, + actor_id=current_user.sub, + actor_name=current_user.name, ) @@ -100,6 +104,8 @@ async def update_staff_password( staff_id=staff_id, new_password=body.new_password, current_user_role=current_user.role, + actor_id=current_user.sub, + actor_name=current_user.name, ) @@ -114,4 +120,6 @@ async def delete_staff( staff_id=staff_id, current_user_role=current_user.role, current_user_id=current_user.sub, + actor_id=current_user.sub, + actor_name=current_user.name, ) diff --git a/backend/staff/service.py b/backend/staff/service.py index f6b9451..c1e2d37 100644 --- a/backend/staff/service.py +++ b/backend/staff/service.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from staff.orm import Staff from auth.utils import hash_password from auth.models import default_permissions_for_role +from shared.audit import log_action, diff from shared.exceptions import NotFoundError, AuthorizationError import uuid @@ -17,7 +18,7 @@ def _now() -> datetime: return datetime.now(timezone.utc) -def _to_response(staff: Staff) -> dict: +def _to_dict(staff: Staff) -> dict: return { "id": staff.id, "email": staff.email, @@ -28,6 +29,10 @@ def _to_response(staff: Staff) -> dict: } +def _to_response(staff: Staff) -> dict: + return _to_dict(staff) + + async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict: stmt = select(Staff) if role_filter: @@ -58,7 +63,13 @@ async def get_staff_me(db: AsyncSession, user_sub: str) -> dict: return await get_staff(db, user_sub) -async def create_staff(db: AsyncSession, data: dict, current_user_role: str) -> dict: +async def create_staff( + db: AsyncSession, + data: dict, + current_user_role: str, + actor_id: str, + actor_name: str, +) -> dict: role = data.get("role", "user") if role not in VALID_ROLES: raise AuthorizationError(f"Invalid role: {role}") @@ -86,10 +97,23 @@ async def create_staff(db: AsyncSession, data: dict, current_user_role: str) -> hashed_password=hash_password(data["password"]), is_active=True, permissions=permissions, + ui_prefs={}, created_at=now, updated_at=now, ) db.add(staff) + await db.flush() + + await log_action( + db, + actor_id=actor_id, + actor_name=actor_name, + action="CREATE", + entity_type="staff", + entity_id=uid, + entity_label=data["email"], + meta={"role": role}, + ) await db.commit() await db.refresh(staff) return _to_response(staff) @@ -101,6 +125,8 @@ async def update_staff( data: dict, current_user_role: str, current_user_id: str, + actor_id: str, + actor_name: str, ) -> dict: result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() @@ -112,6 +138,8 @@ async def update_staff( if current_user_role == "admin" and data.get("role") == "sysadmin": raise AuthorizationError("Admin cannot promote to sysadmin") + old = _to_dict(staff) + if data.get("email") is not None: dup = await db.execute( select(Staff).where(Staff.email == data["email"], Staff.id != staff_id).limit(1) @@ -132,6 +160,20 @@ async def update_staff( staff.permissions = data["permissions"] staff.updated_at = _now() + await db.flush() + + changes = diff(old, _to_dict(staff)) + action = "PERMISSION_CHANGE" if "permissions" in data and len(changes) == 1 else "UPDATE" + await log_action( + db, + actor_id=actor_id, + actor_name=actor_name, + action=action, + entity_type="staff", + entity_id=staff_id, + entity_label=staff.email, + changes=changes or None, + ) await db.commit() await db.refresh(staff) return _to_response(staff) @@ -142,6 +184,8 @@ async def update_staff_password( staff_id: str, new_password: str, current_user_role: str, + actor_id: str, + actor_name: str, ) -> dict: result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() @@ -152,12 +196,23 @@ async def update_staff_password( staff.hashed_password = hash_password(new_password) staff.updated_at = _now() + await db.flush() + + await log_action( + db, + actor_id=actor_id, + actor_name=actor_name, + action="UPDATE", + entity_type="staff", + entity_id=staff_id, + entity_label=staff.email, + meta={"detail": "password changed"}, + ) await db.commit() return {"message": "Password updated successfully"} async def get_preferences(db: AsyncSession, staff_id: str) -> dict: - """Return ui_prefs JSONB for a staff member.""" result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() if staff is None: @@ -166,7 +221,6 @@ async def get_preferences(db: AsyncSession, staff_id: str) -> dict: async def update_preferences(db: AsyncSession, staff_id: str, page_key: str, prefs: dict) -> dict: - """Merge page-level preferences into the staff member's ui_prefs column.""" result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() if staff is None: @@ -185,6 +239,8 @@ async def delete_staff( staff_id: str, current_user_role: str, current_user_id: str, + actor_id: str, + actor_name: str, ) -> dict: if staff_id == current_user_id: raise AuthorizationError("Cannot delete your own account") @@ -197,6 +253,16 @@ async def delete_staff( if current_user_role == "admin" and staff.role == "sysadmin": raise AuthorizationError("Admin cannot delete sysadmin accounts") + label = staff.email + await log_action( + db, + actor_id=actor_id, + actor_name=actor_name, + action="DELETE", + entity_type="staff", + entity_id=staff_id, + entity_label=label, + ) await db.delete(staff) await db.commit() return {"message": "Staff member deleted"} diff --git a/strategies/DATABASE_MIGRATION.md b/strategies/DATABASE_MIGRATION.md index 4b3956f..23bc638 100644 --- a/strategies/DATABASE_MIGRATION.md +++ b/strategies/DATABASE_MIGRATION.md @@ -469,7 +469,7 @@ backend/ | 1 | SQLite → Postgres (data migration) | **COMPLETE** — all 12 scripts ran successfully on VPS 2026-04-17 | | 2 | Firestore → Postgres (data migration) | **COMPLETE** — all 5 scripts ran successfully on VPS 2026-04-17 | | 3 | Staff auth cutover | **COMPLETE** — Postgres auth live 2026-04-17 | -| 4 | Audit log system | NOT STARTED | +| 4 | Audit log system | **COMPLETE** — shared/audit.py live, wired into auth + staff 2026-04-17 | | 5 | MQTT live data cutover | NOT STARTED | Update this table as each phase completes.