From 11bf08a29dd30b0e276efe9f2a4f901d71855beb Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 14 May 2026 11:37:43 +0100 Subject: [PATCH] feat(audit): add audit logging to org and invitation routes Adds audit log entries for all write endpoints in routes_organizations.py (ORG_CREATE, ORG_UPDATE, ORG_MEMBER_ADD, ORG_MEMBER_UPDATE, ORG_MEMBER_REMOVE) and routes_invitations.py (INVITATION_CREATE, INVITATION_REVOKE, INVITATION_ACCEPT). The public accept endpoint passes user=None per the no-auth contract. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/v1/routes_invitations.py | 42 ++++++++++++++- backend/app/api/v1/routes_organizations.py | 62 ++++++++++++++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/routes_invitations.py b/backend/app/api/v1/routes_invitations.py index 8c6ef62..2d6d4ba 100644 --- a/backend/app/api/v1/routes_invitations.py +++ b/backend/app/api/v1/routes_invitations.py @@ -16,7 +16,7 @@ import re import secrets from datetime import UTC, datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from motor.motor_asyncio import AsyncIOMotorDatabase from ...core.authz import bump_user_membership_cache @@ -27,6 +27,7 @@ from ...core.security import ( create_refresh_token, get_password_hash, ) +from ...models.audit_log import AuditAction from ...models.invitation import ( InvitationAcceptRequest, InvitationCreate, @@ -35,6 +36,7 @@ from ...models.invitation import ( ) from ...models.organization import OrgRole from ...models.user import AuthProvider, User, UserRole +from ...services.audit_logger import audit_logger from ...services.emailer import email_service from ...services.membership_service import get_membership, upsert_membership @@ -103,6 +105,7 @@ org_router = APIRouter(prefix="/organizations", tags=["invitations"]) async def create_invitation( org_id: str, body: InvitationCreate, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -169,7 +172,17 @@ async def create_invitation( expires_at=expires_at, ) - return _inv_from_doc(doc) + inv = _inv_from_doc(doc) + await audit_logger.log_action( + action=AuditAction.INVITATION_CREATE, + description=f"Invitation created for '{email_lower}' to organization '{org_id}'", + user=current_user, + request=request, + resource_type="invitation", + resource_id=inv.id, + details={"invited_email": email_lower, "org_id": org_id, "role": body.role_in_org}, + ) + return inv @org_router.get("/{org_id}/invitations", response_model=list[InvitationResponse]) @@ -189,16 +202,30 @@ async def list_invitations( async def revoke_invitation( org_id: str, invitation_id: str, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): await _assert_org_admin(org_id, current_user, db) + inv_doc = await db.invitations.find_one({"_id": invitation_id, "organization_id": org_id}) result = await db.invitations.update_one( {"_id": invitation_id, "organization_id": org_id, "accepted_at": None, "revoked_at": None}, {"$set": {"revoked_at": _now()}}, ) if result.matched_count == 0: raise HTTPException(status_code=404, detail="Invitation not found or already accepted/revoked") + await audit_logger.log_action( + action=AuditAction.INVITATION_REVOKE, + description=f"Invitation '{invitation_id}' revoked in organization '{org_id}'", + user=current_user, + request=request, + resource_type="invitation", + resource_id=invitation_id, + details={ + "invited_email": inv_doc["email"] if inv_doc else None, + "org_id": org_id, + }, + ) # --------------------------------------------------------------------------- @@ -270,6 +297,7 @@ async def preview_invitation( @router.post("/invitations/accept") async def accept_invitation( body: InvitationAcceptRequest, + request: Request, db: AsyncIOMotorDatabase = Depends(get_database), ): """Accept an invitation. Creates user if needed, creates membership, returns tokens.""" @@ -359,6 +387,16 @@ async def accept_invitation( org_name, org_slug = await _get_org_name(org_id, db) + await audit_logger.log_action( + action=AuditAction.INVITATION_ACCEPT, + description=f"Invitation accepted by '{email_lower}' for organization '{org_id}'", + user=None, + request=request, + resource_type="invitation", + resource_id=str(doc["_id"]), + details={"invited_email": email_lower, "org_id": org_id}, + ) + return { "access_token": access_token, "refresh_token": refresh_token, diff --git a/backend/app/api/v1/routes_organizations.py b/backend/app/api/v1/routes_organizations.py index f9dae17..2f85ce7 100644 --- a/backend/app/api/v1/routes_organizations.py +++ b/backend/app/api/v1/routes_organizations.py @@ -14,13 +14,14 @@ endpoints coexist without data duplication. from datetime import UTC, datetime -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.authz import bump_user_membership_cache from ...core.database import get_database from ...core.dependencies import get_current_user, require_roles +from ...models.audit_log import AuditAction from ...models.membership import MemberDetail, MembershipCreate, MembershipUpdate from ...models.organization import ( Organization, @@ -29,6 +30,7 @@ from ...models.organization import ( OrgRole, ) from ...models.user import User, UserRole +from ...services.audit_logger import audit_logger from ...services.membership_service import ( get_membership, get_memberships_for_user, @@ -119,6 +121,7 @@ class _OrgCreate(BaseModel): @router.post("", response_model=Organization, status_code=201) async def create_organization( body: OrganizationCreate, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -137,13 +140,25 @@ async def create_organization( "updated_at": now, } await db.clients.insert_one(doc) - return _org_from_doc(doc) + org = _org_from_doc(doc) + await audit_logger.log_action( + action=AuditAction.ORG_CREATE, + description=f"Organization '{org.name}' created", + user=current_user, + request=request, + resource_type="organization", + resource_id=str(org.id), + resource_name=org.name, + details={"slug": org.slug}, + ) + return org @router.patch("/{org_id}", response_model=Organization) async def update_organization( org_id: str, body: OrganizationUpdate, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -160,7 +175,18 @@ async def update_organization( await db.clients.update_one({"_id": org_id}, {"$set": updates}) updated = {**doc, **updates} - return _org_from_doc(updated) + org = _org_from_doc(updated) + await audit_logger.log_action( + action=AuditAction.ORG_UPDATE, + description=f"Organization '{org.name}' updated", + user=current_user, + request=request, + resource_type="organization", + resource_id=str(org.id), + resource_name=org.name, + details={k: v for k, v in updates.items() if k != "updated_at"}, + ) + return org # --------------------------------------------------------------------------- @@ -182,6 +208,7 @@ async def list_members( async def add_member( org_id: str, body: MembershipCreate, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -197,6 +224,15 @@ async def add_member( members = await list_org_members(org_id, db) for m in members: if m.user_id == body.user_id: + await audit_logger.log_action( + action=AuditAction.ORG_MEMBER_ADD, + description=f"Member '{body.user_id}' added to organization '{org_id}' with role '{body.role_in_org}'", + user=current_user, + request=request, + resource_type="organization", + resource_id=org_id, + details={"user_id": body.user_id, "role": body.role_in_org}, + ) return m raise HTTPException(status_code=500, detail="Membership created but could not be retrieved") @@ -206,6 +242,7 @@ async def update_member( org_id: str, user_id: str, body: MembershipUpdate, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -222,6 +259,15 @@ async def update_member( members = await list_org_members(org_id, db) for m in members: if m.user_id == user_id: + await audit_logger.log_action( + action=AuditAction.ORG_MEMBER_UPDATE, + description=f"Member '{user_id}' role updated in organization '{org_id}' to '{body.role_in_org}'", + user=current_user, + request=request, + resource_type="organization", + resource_id=org_id, + details={"user_id": user_id, "role": body.role_in_org}, + ) return m raise HTTPException(status_code=500, detail="Could not retrieve updated membership") @@ -230,6 +276,7 @@ async def update_member( async def remove_member( org_id: str, user_id: str, + request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -243,6 +290,15 @@ async def remove_member( await remove_membership(user_id, org_id, db) await bump_user_membership_cache(user_id) + await audit_logger.log_action( + action=AuditAction.ORG_MEMBER_REMOVE, + description=f"Member '{user_id}' removed from organization '{org_id}'", + user=current_user, + request=request, + resource_type="organization", + resource_id=org_id, + details={"user_id": user_id, "role": existing.role_in_org}, + ) # ---------------------------------------------------------------------------