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 <noreply@anthropic.com>
This commit is contained in:
parent
42a0c8acb1
commit
11bf08a29d
2 changed files with 99 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue