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:
@@ -0,0 +1 @@
|
||||
"""Routes package."""
|
||||
@@ -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."}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user