From bd1dd694675472091a56e59620ec70511323bcbb Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 14 May 2026 11:33:58 +0100 Subject: [PATCH] feat(audit): add audit logging to client management routes All 13 write endpoints in routes_clients.py now emit audit log entries (CLIENT_CREATE, CLIENT_UPDATE, CLIENT_DEACTIVATE, CLIENT_PM_ASSIGN/REMOVE, CLIENT_TEAM_CREATE/UPDATE/DELETE, CLIENT_TEAM_MEMBER_ADD/REMOVE, CLIENT_PROJECT_CREATE/UPDATE/ARCHIVE). request: Request added to each endpoint signature; resource_name and relevant details included in every call. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/v1/routes_clients.py | 200 ++++++++++++++++++++++++--- 1 file changed, 177 insertions(+), 23 deletions(-) diff --git a/backend/app/api/v1/routes_clients.py b/backend/app/api/v1/routes_clients.py index 75bb6d9..225faca 100644 --- a/backend/app/api/v1/routes_clients.py +++ b/backend/app/api/v1/routes_clients.py @@ -12,12 +12,13 @@ Access rules: from datetime import UTC, datetime from bson import ObjectId -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from motor.motor_asyncio import AsyncIOMotorDatabase from pydantic import BaseModel from ...core.database import get_database from ...core.dependencies import get_current_user, require_roles +from ...models.audit_log import AuditAction from ...models.client import ( Client, ClientCreate, @@ -30,6 +31,7 @@ from ...models.client import ( TeamUpdate, ) from ...models.user import User, UserRole +from ...services.audit_logger import audit_logger router = APIRouter(prefix="/clients", tags=["clients"]) @@ -121,6 +123,7 @@ async def list_clients( @router.post("", response_model=Client) async def create_client( body: ClientCreate, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -137,7 +140,18 @@ async def create_client( "updated_at": now, }) doc = await db.clients.find_one({"_id": client_id}) - return _client_from_doc(doc) + client = _client_from_doc(doc) + await audit_logger.log_action( + action=AuditAction.CLIENT_CREATE, + description=f"Client '{client.name}' created", + user=current_user, + request=request, + resource_type="client", + resource_id=str(client.id), + resource_name=client.name, + details={"slug": client.slug}, + ) + return client @router.get("/{client_id}", response_model=Client) @@ -158,6 +172,7 @@ async def get_client( async def update_client( client_id: str, body: ClientUpdate, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -170,17 +185,39 @@ async def update_client( update["updated_at"] = _now() await db.clients.update_one({"_id": client_id}, {"$set": update}) doc = await db.clients.find_one({"_id": client_id}) - return _client_from_doc(doc) + client = _client_from_doc(doc) + await audit_logger.log_action( + action=AuditAction.CLIENT_UPDATE, + description=f"Client '{client.name}' updated", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client.name, + details={"fields_updated": list(body.model_dump(exclude_none=True).keys())}, + ) + return client @router.delete("/{client_id}", status_code=204) async def deactivate_client( client_id: str, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + doc = await _get_client_or_404(client_id, db) await db.clients.update_one({"_id": client_id}, {"$set": {"is_active": False, "updated_at": _now()}}) + await audit_logger.log_action( + action=AuditAction.CLIENT_DEACTIVATE, + description=f"Client '{doc['name']}' deactivated", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=doc["name"], + details={}, + ) # --------------------------------------------------------------------------- @@ -195,10 +232,11 @@ class AssignPMRequest(BaseModel): async def assign_pm( client_id: str, body: AssignPMRequest, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) user_doc = await db.users.find_one({"_id": body.user_id}) if not user_doc: raise HTTPException(status_code=404, detail="User not found") @@ -209,16 +247,28 @@ async def assign_pm( "$set": {"role": UserRole.PROJECT_MANAGER.value, "updated_at": _now()}, }, ) + await audit_logger.log_action( + action=AuditAction.CLIENT_PM_ASSIGN, + description=f"PM '{user_doc.get('email', body.user_id)}' assigned to client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"pm_user_id": body.user_id, "pm_email": user_doc.get("email")}, + ) @router.delete("/{client_id}/pm/{user_id}", status_code=204) async def remove_pm( client_id: str, user_id: str, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) + pm_doc = await db.users.find_one({"_id": user_id}) await db.users.update_one( {"_id": user_id}, {"$pull": {"pm_client_ids": client_id}, "$set": {"updated_at": _now()}}, @@ -230,6 +280,16 @@ async def remove_pm( {"_id": user_id}, {"$set": {"role": UserRole.CLIENT.value, "updated_at": _now()}}, ) + await audit_logger.log_action( + action=AuditAction.CLIENT_PM_REMOVE, + description=f"PM '{pm_doc.get('email', user_id) if pm_doc else user_id}' removed from client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"pm_user_id": user_id, "pm_email": pm_doc.get("email") if pm_doc else None}, + ) @router.get("/{client_id}/pm", response_model=list[dict]) @@ -266,10 +326,11 @@ async def list_teams( async def create_team( client_id: str, body: TeamCreate, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) await _assert_pm_or_admin(current_user, client_id, db) now = _now() team_id = str(ObjectId()) @@ -282,7 +343,18 @@ async def create_team( "updated_at": now, }) doc = await db.teams.find_one({"_id": team_id}) - return _team_from_doc(doc) + team = _team_from_doc(doc) + await audit_logger.log_action( + action=AuditAction.CLIENT_TEAM_CREATE, + description=f"Team '{team.name}' created for client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"team_id": team_id, "team_name": team.name}, + ) + return team @router.patch("/{client_id}/teams/{team_id}", response_model=Team) @@ -290,10 +362,11 @@ async def update_team( client_id: str, team_id: str, body: TeamUpdate, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) await _assert_pm_or_admin(current_user, client_id, db) await _get_team_or_404(team_id, client_id, db) update = dict(body.model_dump(exclude_none=True).items()) @@ -302,20 +375,42 @@ async def update_team( update["updated_at"] = _now() await db.teams.update_one({"_id": team_id}, {"$set": update}) doc = await db.teams.find_one({"_id": team_id}) - return _team_from_doc(doc) + team = _team_from_doc(doc) + await audit_logger.log_action( + action=AuditAction.CLIENT_TEAM_UPDATE, + description=f"Team '{team.name}' updated for client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"team_id": team_id, "team_name": team.name, "fields_updated": list(body.model_dump(exclude_none=True).keys())}, + ) + return team @router.delete("/{client_id}/teams/{team_id}", status_code=204) async def delete_team( client_id: str, team_id: str, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) await _assert_pm_or_admin(current_user, client_id, db) - await _get_team_or_404(team_id, client_id, db) + team_doc = await _get_team_or_404(team_id, client_id, db) await db.teams.delete_one({"_id": team_id}) + await audit_logger.log_action( + action=AuditAction.CLIENT_TEAM_DELETE, + description=f"Team '{team_doc['name']}' deleted from client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"team_id": team_id, "team_name": team_doc["name"]}, + ) # Team membership @@ -329,13 +424,15 @@ async def add_team_member( client_id: str, team_id: str, body: AddMemberRequest, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) await _assert_pm_or_admin(current_user, client_id, db) - await _get_team_or_404(team_id, client_id, db) - if not await db.users.find_one({"_id": body.user_id}): + team_doc = await _get_team_or_404(team_id, client_id, db) + member_doc = await db.users.find_one({"_id": body.user_id}) + if not member_doc: raise HTTPException(status_code=404, detail="User not found") # Write to both Team.member_user_ids (legacy) and Membership.team_ids (MT-17) await db.teams.update_one( @@ -346,6 +443,16 @@ async def add_team_member( {"user_id": body.user_id, "organization_id": client_id}, {"$addToSet": {"team_ids": team_id}}, ) + await audit_logger.log_action( + action=AuditAction.CLIENT_TEAM_MEMBER_ADD, + description=f"User '{member_doc.get('email', body.user_id)}' added to team '{team_doc['name']}' of client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"team_id": team_id, "team_name": team_doc["name"], "member_user_id": body.user_id, "member_email": member_doc.get("email")}, + ) @router.delete("/{client_id}/teams/{team_id}/members/{user_id}", status_code=204) @@ -353,12 +460,14 @@ async def remove_team_member( client_id: str, team_id: str, user_id: str, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) await _assert_pm_or_admin(current_user, client_id, db) - await _get_team_or_404(team_id, client_id, db) + team_doc = await _get_team_or_404(team_id, client_id, db) + member_doc = await db.users.find_one({"_id": user_id}) await db.teams.update_one( {"_id": team_id}, {"$pull": {"member_user_ids": user_id}, "$set": {"updated_at": _now()}}, @@ -367,6 +476,16 @@ async def remove_team_member( {"user_id": user_id, "organization_id": client_id}, {"$pull": {"team_ids": team_id}}, ) + await audit_logger.log_action( + action=AuditAction.CLIENT_TEAM_MEMBER_REMOVE, + description=f"User '{member_doc.get('email', user_id) if member_doc else user_id}' removed from team '{team_doc['name']}' of client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"team_id": team_id, "team_name": team_doc["name"], "member_user_id": user_id, "member_email": member_doc.get("email") if member_doc else None}, + ) # --------------------------------------------------------------------------- @@ -407,10 +526,11 @@ async def list_projects( async def create_project( client_id: str, body: ProjectCreate, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) await _assert_pm_or_client_member(current_user, client_id, db) now = _now() project_id = str(ObjectId()) @@ -426,7 +546,18 @@ async def create_project( "updated_at": now, }) doc = await db.projects.find_one({"_id": project_id}) - return _project_from_doc(doc) + project = _project_from_doc(doc) + await audit_logger.log_action( + action=AuditAction.CLIENT_PROJECT_CREATE, + description=f"Project '{project.name}' created for client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"project_id": project_id, "project_name": project.name, "default_languages": body.default_languages}, + ) + return project @router.patch("/{client_id}/projects/{project_id}", response_model=Project) @@ -434,10 +565,11 @@ async def update_project( client_id: str, project_id: str, body: ProjectUpdate, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) await _assert_pm_or_admin(current_user, client_id, db) await _get_project_or_404(project_id, client_id, db) update = dict(body.model_dump(exclude_none=True).items()) @@ -446,23 +578,45 @@ async def update_project( update["updated_at"] = _now() await db.projects.update_one({"_id": project_id}, {"$set": update}) doc = await db.projects.find_one({"_id": project_id}) - return _project_from_doc(doc) + project = _project_from_doc(doc) + await audit_logger.log_action( + action=AuditAction.CLIENT_PROJECT_UPDATE, + description=f"Project '{project.name}' updated for client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"project_id": project_id, "project_name": project.name, "fields_updated": list(body.model_dump(exclude_none=True).keys())}, + ) + return project @router.delete("/{client_id}/projects/{project_id}", status_code=204) async def archive_project( client_id: str, project_id: str, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): - await _get_client_or_404(client_id, db) + client_doc = await _get_client_or_404(client_id, db) await _assert_pm_or_admin(current_user, client_id, db) - await _get_project_or_404(project_id, client_id, db) + project_doc = await _get_project_or_404(project_id, client_id, db) await db.projects.update_one( {"_id": project_id}, {"$set": {"is_active": False, "updated_at": _now()}}, ) + await audit_logger.log_action( + action=AuditAction.CLIENT_PROJECT_ARCHIVE, + description=f"Project '{project_doc['name']}' archived for client '{client_doc['name']}'", + user=current_user, + request=request, + resource_type="client", + resource_id=client_id, + resource_name=client_doc["name"], + details={"project_id": project_id, "project_name": project_doc["name"]}, + ) # ---------------------------------------------------------------------------