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 <noreply@anthropic.com>
This commit is contained in:
parent
82d438df7c
commit
bd1dd69467
1 changed files with 177 additions and 23 deletions
|
|
@ -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"]},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue