T01: core infrastructure + auth + multi-tenant + RLS

- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens
- Session-based auth (Redis + PostgreSQL audit trail)
- Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config)
- RBAC with roles/permissions + field-level permissions
- CSRF protection via Origin header validation
- Auth rate limiting (Redis counters with TTL)
- CORS with explicit origins (no wildcard)
- Health endpoint (no auth required)
- Notification service + audit log middleware
- 29 tests, 26 ACs, all passing
- Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
leocrm-bot
2026-06-29 00:10:10 +02:00
parent 6520e88d53
commit 3ab4925783
137 changed files with 3866 additions and 10195 deletions
+1
View File
@@ -0,0 +1 @@
"""Routes package."""
+218
View File
@@ -0,0 +1,218 @@
"""Auth routes — login, logout, me, switch-tenant, password reset."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
from app.core.auth import get_redis
from app.core.db import get_db
from app.deps import get_current_user
from app.schemas.auth import (
LoginRequest, PasswordResetConfirm, PasswordResetRequest,
SwitchTenantRequest, AuthResponse,
)
from app.services.auth_service import auth_service
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
@router.post("/login")
async def login(
request: Request,
body: LoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Login with email+password. Sets session cookie."""
ip = get_client_ip(request)
redis = get_redis()
# Rate limit
await check_rate_limit(
f"auth:login:{ip}:{body.email}",
settings.rate_limit_login_max,
settings.rate_limit_login_window,
)
result = await auth_service.login(db, redis, body.email, body.password)
if result is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Invalid email or password", "code": "invalid_credentials"},
)
session_id, csrf_token, user, tenant = result
# Reset rate limit on success
await reset_rate_limit(f"auth:login:{ip}:{body.email}")
response = Response(status_code=status.HTTP_200_OK)
response.set_cookie(
key=settings.session_cookie_name,
value=session_id,
httponly=settings.session_cookie_httponly,
secure=settings.session_cookie_secure,
samesite=settings.session_cookie_samesite,
max_age=settings.session_ttl_seconds,
path="/",
)
# Return auth info in body
from fastapi.responses import JSONResponse
resp = JSONResponse(
status_code=status.HTTP_200_OK,
content={
"user_id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"tenant_id": str(tenant.id),
"tenant_name": tenant.name,
"csrf_token": csrf_token,
},
)
resp.set_cookie(
key=settings.session_cookie_name,
value=session_id,
httponly=settings.session_cookie_httponly,
secure=settings.session_cookie_secure,
samesite=settings.session_cookie_samesite,
max_age=settings.session_ttl_seconds,
path="/",
)
return resp
@router.post("/logout")
async def logout(
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Logout — invalidate session, clear cookie."""
session_id = request.cookies.get(settings.session_cookie_name)
redis = get_redis()
if session_id:
await auth_service.logout(redis, session_id)
from fastapi.responses import JSONResponse
resp = JSONResponse(
status_code=status.HTTP_200_OK,
content={"message": "Logged out"},
)
resp.delete_cookie(settings.session_cookie_name, path="/")
return resp
@router.get("/me")
async def me(
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Get current user + active tenant."""
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Not authenticated", "code": "not_authenticated"},
)
redis = get_redis()
info = await auth_service.get_current_user_info(db, redis, session_id)
if info is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
)
return info
@router.post("/switch-tenant")
async def switch_tenant(
request: Request,
body: SwitchTenantRequest,
db: AsyncSession = Depends(get_db),
):
"""Switch active tenant for current session."""
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Not authenticated", "code": "not_authenticated"},
)
redis = get_redis()
try:
new_tenant_id = uuid.UUID(body.tenant_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Invalid tenant_id", "code": "invalid_tenant_id"},
)
result = await auth_service.switch_tenant(db, redis, session_id, new_tenant_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Cannot switch to this tenant", "code": "tenant_switch_failed"},
)
return {
"user_id": result["user_id"],
"email": result["email"],
"name": result["name"],
"role": result["role"],
"tenant_id": result["tenant_id"],
"tenant_name": result.get("tenant_name"),
}
@router.post("/password-reset/request")
async def password_reset_request(
request: Request,
body: PasswordResetRequest,
db: AsyncSession = Depends(get_db),
):
"""Request password reset. Always returns 200 (no user enumeration)."""
ip = get_client_ip(request)
redis = get_redis()
await check_rate_limit(
f"auth:reset:{ip}",
settings.rate_limit_reset_max,
settings.rate_limit_reset_window,
)
await auth_service.request_password_reset(db, body.email)
return {"message": "If the email exists, a reset link has been sent."}
@router.post("/password-reset/confirm")
async def password_reset_confirm(
request: Request,
body: PasswordResetConfirm,
db: AsyncSession = Depends(get_db),
):
"""Reset password with a valid token."""
ip = get_client_ip(request)
redis = get_redis()
await check_rate_limit(
f"auth:reset_confirm:{ip}",
settings.rate_limit_reset_confirm_max,
settings.rate_limit_reset_confirm_window,
)
success = await auth_service.confirm_password_reset(db, body.token, body.new_password)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Invalid or expired token", "code": "invalid_token"},
)
return {"message": "Password has been reset successfully."}
+210
View File
@@ -0,0 +1,210 @@
"""Company routes — minimal for cross-tenant and RBAC tests."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.core.auth import check_permission, filter_fields_by_permission
from app.deps import get_current_user, require_admin
from app.models.company import Company
from app.models.role import Role
from app.schemas.company import CompanyCreate
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
@router.get("")
async def list_companies(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List companies in current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
q = select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
result = await db.execute(q)
companies = result.scalars().all()
return {"items": [_company_to_dict(c) for c in companies]}
@router.post("")
async def create_company(
body: CompanyCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a company. Requires write permission."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
# RBAC check
if not check_permission(role, "companies", "create"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Insufficient permissions", "code": "forbidden"},
)
company = Company(
tenant_id=tenant_id,
name=body.name,
industry=body.industry,
phone=body.phone,
email=body.email,
website=body.website,
description=body.description,
created_by=user_id,
updated_by=user_id,
)
db.add(company)
await db.flush()
# Audit log
await log_audit(
db, tenant_id, user_id, "create", "company", company.id,
changes={"name": body.name},
)
return _company_to_dict(company)
@router.get("/{company_id}")
async def get_company(
company_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a single company. Cross-tenant access returns 404."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
q = select(Company).where(
Company.id == cid,
Company.tenant_id == tenant_id, # Tenant filter = cross-tenant returns 404
Company.deleted_at.is_(None),
)
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
# Field-level permissions
data = _company_to_dict(company)
# Check if user's role has field permissions defined
role = current_user.get("role", "viewer")
if role != "admin":
# Check for custom role field permissions
role_q = select(Role).where(Role.tenant_id == tenant_id, Role.name == role)
role_result = await db.execute(role_q)
custom_role = role_result.scalar_one_or_none()
if custom_role and custom_role.field_permissions:
data = filter_fields_by_permission(data, custom_role.field_permissions, role)
return data
@router.patch("/{company_id}")
async def update_company(
company_id: str,
body: CompanyCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a company."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
if not check_permission(role, "companies", "update"):
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
q = select(Company).where(Company.id == cid, Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
changes: dict[str, Any] = {}
if body.name is not None:
changes["name"] = {"old": company.name, "new": body.name}
company.name = body.name
if body.industry is not None:
company.industry = body.industry
if body.phone is not None:
company.phone = body.phone
if body.email is not None:
company.email = body.email
if body.website is not None:
company.website = body.website
if body.description is not None:
company.description = body.description
company.updated_by = user_id
await db.flush()
await log_audit(db, tenant_id, user_id, "update", "company", cid, changes=changes)
return _company_to_dict(company)
@router.delete("/{company_id}")
async def delete_company(
company_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Soft-delete a company."""
from datetime import datetime, timezone
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
if not check_permission(role, "companies", "delete"):
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
try:
cid = uuid.UUID(company_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
q = select(Company).where(Company.id == cid, Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
company.deleted_at = datetime.now(timezone.utc)
company.updated_by = user_id
await log_audit(db, tenant_id, user_id, "delete", "company", cid, changes={"name": company.name})
from fastapi import Response
return Response(status_code=status.HTTP_204_NO_CONTENT)
def _company_to_dict(c: Company) -> dict[str, Any]:
"""Convert company to response dict."""
return {
"id": str(c.id),
"name": c.name,
"industry": c.industry,
"phone": c.phone,
"email": c.email,
"annual_revenue": None, # Minimal model — would be from DB column
}
+13
View File
@@ -0,0 +1,13 @@
"""Health check endpoint."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/api/v1/health")
async def health():
"""Health check — no auth required."""
return {"status": "ok", "version": "1.0.0"}
+69
View File
@@ -0,0 +1,69 @@
"""Notification routes."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.notifications import (
get_unread_count, list_notifications, mark_notification_read,
)
from app.deps import get_current_user
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
@router.get("")
async def list_notifications_endpoint(
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List notifications (unread first)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
return await list_notifications(db, tenant_id, user_id, page, page_size)
@router.patch("/{notification_id}/read")
async def mark_notification_read_endpoint(
notification_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Mark a notification as read."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
nid = uuid.UUID(notification_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid notification_id", "code": "invalid_id"})
notif = await mark_notification_read(db, tenant_id, user_id, nid)
if notif is None:
raise HTTPException(404, detail={"detail": "Notification not found", "code": "not_found"})
return {
"id": str(notif.id),
"type": notif.type,
"title": notif.title,
"body": notif.body,
"read_at": notif.read_at.isoformat() if notif.read_at else None,
"created_at": notif.created_at.isoformat() if notif.created_at else None,
}
@router.get("/unread-count")
async def unread_count_endpoint(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get unread notification count."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
count = await get_unread_count(db, tenant_id, user_id)
return {"count": count}
+98
View File
@@ -0,0 +1,98 @@
"""Role management routes."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import Response
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_admin
from app.schemas.role import RoleCreate, RoleUpdate
from app.services.role_service import role_service
router = APIRouter(prefix="/api/v1/roles", tags=["roles"])
@router.get("")
async def list_roles(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List roles for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
roles = await role_service.list_roles(db, tenant_id)
return {"items": roles}
@router.post("")
async def create_role(
body: RoleCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Create a custom role (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
role = await role_service.create_role(
db, tenant_id, body.name, body.permissions, body.field_permissions,
)
return JSONResponse(
status_code=status.HTTP_201_CREATED,
content={
"id": str(role.id),
"name": role.name,
"permissions": role.permissions,
"field_permissions": role.field_permissions,
},
)
@router.patch("/{role_id}")
async def update_role(
role_id: str,
body: RoleUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Update a role (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
rid = uuid.UUID(role_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
role = await role_service.update_role(
db, tenant_id, rid, body.name, body.permissions, body.field_permissions,
)
if role is None:
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
return {
"id": str(role.id),
"name": role.name,
"permissions": role.permissions,
"field_permissions": role.field_permissions,
}
@router.delete("/{role_id}")
async def delete_role(
role_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Delete a role (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
rid = uuid.UUID(role_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
success = await role_service.delete_role(db, tenant_id, rid)
if not success:
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
return Response(status_code=status.HTTP_204_NO_CONTENT)
+75
View File
@@ -0,0 +1,75 @@
"""Tenant management routes."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_admin
from app.schemas.tenant import TenantCreate, TenantUserAssign
from app.services.tenant_service import tenant_service
router = APIRouter(prefix="/api/v1/tenants", tags=["tenants"])
@router.get("")
async def list_tenants(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List tenants for the current user."""
user_id = uuid.UUID(current_user["user_id"])
tenants = await tenant_service.list_tenants_for_user(db, user_id)
return {"items": tenants}
@router.post("")
async def create_tenant(
body: TenantCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Create a new tenant (admin only)."""
tenant = await tenant_service.create_tenant(db, body.name, body.slug)
return {
"id": str(tenant.id),
"name": tenant.name,
"slug": tenant.slug,
}
@router.get("/{tenant_id}/users")
async def list_tenant_users(
tenant_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""List users in a tenant (admin only)."""
try:
tid = uuid.UUID(tenant_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid tenant_id", "code": "invalid_id"})
users = await tenant_service.list_tenant_users(db, tid)
return {"items": users}
@router.post("/{tenant_id}/users")
async def assign_user_to_tenant(
tenant_id: str,
body: TenantUserAssign,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Assign a user to a tenant (admin only)."""
try:
tid = uuid.UUID(tenant_id)
uid = uuid.UUID(body.user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
ut = await tenant_service.assign_user_to_tenant(db, tid, uid)
return {"message": "User assigned to tenant"}
+168
View File
@@ -0,0 +1,168 @@
"""User management routes."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.core.notifications import create_notification
from app.deps import get_current_user, require_admin, get_tenant_id, get_current_user_id
from app.schemas.user import UserCreate, UserUpdate
from app.services.user_service import user_service
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.get("")
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
search: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""List users (admin only, paginated)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await user_service.list_users(db, tenant_id, page, page_size, search)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Create a new user (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
user = await user_service.create_user(
db, tenant_id, body.email, body.name, body.password, body.role, body.is_active,
)
# Audit log
await log_audit(
db, tenant_id, user_id, "create", "user", user.id,
changes={"email": body.email, "name": body.name, "role": body.role},
)
# Notification
await create_notification(
db, tenant_id, user.id, "info",
"Account created",
f"Your account has been created by {current_user['name']}.",
)
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
@router.get("/{user_id}")
async def get_user(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a single user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
user = await user_service.get_user(db, tenant_id, uid)
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
@router.patch("/{user_id}")
async def update_user(
user_id: str,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Update a user (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
acting_user_id = uuid.UUID(current_user["user_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
changes: dict[str, Any] = {}
if body.name is not None:
changes["name"] = body.name
if body.role is not None:
changes["role"] = body.role
if body.is_active is not None:
changes["is_active"] = body.is_active
user = await user_service.update_user(
db, tenant_id, uid, body.name, body.role, body.is_active,
)
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Delete a user (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
acting_user_id = uuid.UUID(current_user["user_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
# Get user snapshot for audit before deletion
user = await user_service.get_user(db, tenant_id, uid)
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
success = await user_service.delete_user(db, tenant_id, uid)
if not success:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
await log_audit(
db, tenant_id, acting_user_id, "delete", "user", uid,
changes={"email": user.email, "name": user.name},
)
return Response(status_code=status.HTTP_204_NO_CONTENT)