authz.py (new): - MembershipContext — per-request membership dict for the current user - get_membership_context FastAPI dependency - require_org_role(min_role) — dependency factory keyed off org_id path param - require_platform_admin() - OrgScopedQuery — adds organization_id filter; platform admin passes through - bump_user_membership_cache — invalidates Redis key on membership writes dependencies.py: - get_accessible_project_ids now queries memberships collection first; legacy pm_client_ids / team.member_user_ids fallback retained until migration runs (four job-route access checks at lines 608/1054/1181/1538 are fixed via this function) routes_clients.py: - _assert_pm_or_admin and _assert_client_access are now async and query memberships - All 10 call sites updated with await + db arg emailer.py: - Switched from SendGrid to Mailgun REST API via httpx (already in requirements) - _send() is now fully async; same public method signatures preserved - send_completion_email uses _send() config.py: - Added mailgun_api_key, mailgun_domain, mailgun_from settings - sendgrid_api_key kept with empty default for backward compat migration_2026-04-28-000003: - Backfills job.organization_id from project.client_id - Creates (organization_id, status, created_at) sparse index on jobs routes_organizations.py / routes_invitations.py: - Call bump_user_membership_cache after every membership write Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
465 lines
16 KiB
Python
465 lines
16 KiB
Python
"""
|
|
Client / Team / Project management.
|
|
|
|
Access rules:
|
|
- Client CRUD → Admin only
|
|
- PM assignment on client → Admin only
|
|
- Team CRUD + membership → Admin or PM of that client
|
|
- Project CRUD → Admin or PM of that client
|
|
- List projects (read) → Admin, PM, or any team member of the client
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from bson import ObjectId
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
from pydantic import BaseModel
|
|
|
|
from ...core.database import get_database
|
|
from ...core.dependencies import get_current_user, require_pm_for_client, require_roles
|
|
from ...models.client import (
|
|
Client,
|
|
ClientCreate,
|
|
ClientUpdate,
|
|
Project,
|
|
ProjectCreate,
|
|
ProjectUpdate,
|
|
Team,
|
|
TeamCreate,
|
|
TeamUpdate,
|
|
)
|
|
from ...models.user import User, UserRole
|
|
|
|
router = APIRouter(prefix="/clients", tags=["clients"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
async def _get_client_or_404(client_id: str, db: AsyncIOMotorDatabase) -> dict:
|
|
doc = await db.clients.find_one({"_id": client_id})
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Client not found")
|
|
return doc
|
|
|
|
|
|
async def _get_team_or_404(team_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict:
|
|
doc = await db.teams.find_one({"_id": team_id, "client_id": client_id})
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Team not found")
|
|
return doc
|
|
|
|
|
|
async def _get_project_or_404(project_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict:
|
|
doc = await db.projects.find_one({"_id": project_id, "client_id": client_id})
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
return doc
|
|
|
|
|
|
def _client_from_doc(doc: dict) -> Client:
|
|
return Client(
|
|
id=str(doc["_id"]),
|
|
name=doc["name"],
|
|
slug=doc["slug"],
|
|
is_active=doc.get("is_active", True),
|
|
created_at=doc.get("created_at"),
|
|
updated_at=doc.get("updated_at"),
|
|
)
|
|
|
|
|
|
def _team_from_doc(doc: dict) -> Team:
|
|
return Team(
|
|
id=str(doc["_id"]),
|
|
name=doc["name"],
|
|
client_id=doc["client_id"],
|
|
member_user_ids=doc.get("member_user_ids", []),
|
|
created_at=doc.get("created_at"),
|
|
updated_at=doc.get("updated_at"),
|
|
)
|
|
|
|
|
|
def _project_from_doc(doc: dict) -> Project:
|
|
return Project(
|
|
id=str(doc["_id"]),
|
|
name=doc["name"],
|
|
client_id=doc["client_id"],
|
|
is_active=doc.get("is_active", True),
|
|
created_at=doc.get("created_at"),
|
|
updated_at=doc.get("updated_at"),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client endpoints (Admin only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("", response_model=list[Client])
|
|
async def list_clients(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
if current_user.role == UserRole.ADMIN:
|
|
docs = await db.clients.find().to_list(None)
|
|
elif current_user.role == UserRole.PROJECT_MANAGER:
|
|
client_ids = current_user.pm_client_ids or []
|
|
docs = await db.clients.find({"_id": {"$in": client_ids}}).to_list(None)
|
|
else:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
return [_client_from_doc(d) for d in docs]
|
|
|
|
|
|
@router.post("", response_model=Client)
|
|
async def create_client(
|
|
body: ClientCreate,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
if await db.clients.find_one({"slug": body.slug}):
|
|
raise HTTPException(status_code=409, detail="Slug already exists")
|
|
now = _now()
|
|
client_id = str(ObjectId())
|
|
await db.clients.insert_one({
|
|
"_id": client_id,
|
|
"name": body.name,
|
|
"slug": body.slug,
|
|
"is_active": True,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
})
|
|
doc = await db.clients.find_one({"_id": client_id})
|
|
return _client_from_doc(doc)
|
|
|
|
|
|
@router.get("/{client_id}", response_model=Client)
|
|
async def get_client(
|
|
client_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PROJECT_MANAGER):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
if current_user.role == UserRole.PROJECT_MANAGER and client_id not in (current_user.pm_client_ids or []):
|
|
raise HTTPException(status_code=403, detail="Not a manager for this client")
|
|
doc = await _get_client_or_404(client_id, db)
|
|
return _client_from_doc(doc)
|
|
|
|
|
|
@router.patch("/{client_id}", response_model=Client)
|
|
async def update_client(
|
|
client_id: str,
|
|
body: ClientUpdate,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
await _get_client_or_404(client_id, db)
|
|
update: dict = {k: v for k, v in body.model_dump(exclude_none=True).items()}
|
|
if not update:
|
|
raise HTTPException(status_code=422, detail="No fields to update")
|
|
if "slug" in update and await db.clients.find_one({"slug": update["slug"], "_id": {"$ne": client_id}}):
|
|
raise HTTPException(status_code=409, detail="Slug already exists")
|
|
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)
|
|
|
|
|
|
@router.delete("/{client_id}", status_code=204)
|
|
async def deactivate_client(
|
|
client_id: str,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
await _get_client_or_404(client_id, db)
|
|
await db.clients.update_one({"_id": client_id}, {"$set": {"is_active": False, "updated_at": _now()}})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PM assignment (Admin only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class AssignPMRequest(BaseModel):
|
|
user_id: str
|
|
|
|
|
|
@router.post("/{client_id}/pm", status_code=204)
|
|
async def assign_pm(
|
|
client_id: str,
|
|
body: AssignPMRequest,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
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")
|
|
await db.users.update_one(
|
|
{"_id": body.user_id},
|
|
{
|
|
"$addToSet": {"pm_client_ids": client_id},
|
|
"$set": {"role": UserRole.PROJECT_MANAGER.value, "updated_at": _now()},
|
|
},
|
|
)
|
|
|
|
|
|
@router.delete("/{client_id}/pm/{user_id}", status_code=204)
|
|
async def remove_pm(
|
|
client_id: str,
|
|
user_id: str,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
await _get_client_or_404(client_id, db)
|
|
await db.users.update_one(
|
|
{"_id": user_id},
|
|
{"$pull": {"pm_client_ids": client_id}, "$set": {"updated_at": _now()}},
|
|
)
|
|
# Downgrade role if no more PM assignments
|
|
user_doc = await db.users.find_one({"_id": user_id})
|
|
if user_doc and not user_doc.get("pm_client_ids"):
|
|
await db.users.update_one(
|
|
{"_id": user_id},
|
|
{"$set": {"role": UserRole.CLIENT.value, "updated_at": _now()}},
|
|
)
|
|
|
|
|
|
@router.get("/{client_id}/pm", response_model=list[dict])
|
|
async def list_pms(
|
|
client_id: str,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
await _get_client_or_404(client_id, db)
|
|
users = await db.users.find(
|
|
{"pm_client_ids": client_id},
|
|
{"_id": 1, "email": 1, "full_name": 1, "role": 1},
|
|
).to_list(None)
|
|
return [{"id": str(u["_id"]), "email": u["email"], "full_name": u["full_name"], "role": u["role"]} for u in users]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Team endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/{client_id}/teams", response_model=list[Team])
|
|
async def list_teams(
|
|
client_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
await _get_client_or_404(client_id, db)
|
|
await _assert_client_access(current_user, client_id, db)
|
|
docs = await db.teams.find({"client_id": client_id}).to_list(None)
|
|
return [_team_from_doc(d) for d in docs]
|
|
|
|
|
|
@router.post("/{client_id}/teams", response_model=Team)
|
|
async def create_team(
|
|
client_id: str,
|
|
body: TeamCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
await _get_client_or_404(client_id, db)
|
|
await _assert_pm_or_admin(current_user, client_id, db)
|
|
now = _now()
|
|
team_id = str(ObjectId())
|
|
await db.teams.insert_one({
|
|
"_id": team_id,
|
|
"name": body.name,
|
|
"client_id": client_id,
|
|
"member_user_ids": [],
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
})
|
|
doc = await db.teams.find_one({"_id": team_id})
|
|
return _team_from_doc(doc)
|
|
|
|
|
|
@router.patch("/{client_id}/teams/{team_id}", response_model=Team)
|
|
async def update_team(
|
|
client_id: str,
|
|
team_id: str,
|
|
body: TeamUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
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 = {k: v for k, v in body.model_dump(exclude_none=True).items()}
|
|
if not update:
|
|
raise HTTPException(status_code=422, detail="No fields to update")
|
|
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)
|
|
|
|
|
|
@router.delete("/{client_id}/teams/{team_id}", status_code=204)
|
|
async def delete_team(
|
|
client_id: str,
|
|
team_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
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)
|
|
await db.teams.delete_one({"_id": team_id})
|
|
|
|
|
|
# Team membership
|
|
|
|
class AddMemberRequest(BaseModel):
|
|
user_id: str
|
|
|
|
|
|
@router.post("/{client_id}/teams/{team_id}/members", status_code=204)
|
|
async def add_team_member(
|
|
client_id: str,
|
|
team_id: str,
|
|
body: AddMemberRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
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}):
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
await db.teams.update_one(
|
|
{"_id": team_id},
|
|
{"$addToSet": {"member_user_ids": body.user_id}, "$set": {"updated_at": _now()}},
|
|
)
|
|
|
|
|
|
@router.delete("/{client_id}/teams/{team_id}/members/{user_id}", status_code=204)
|
|
async def remove_team_member(
|
|
client_id: str,
|
|
team_id: str,
|
|
user_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
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)
|
|
await db.teams.update_one(
|
|
{"_id": team_id},
|
|
{"$pull": {"member_user_ids": user_id}, "$set": {"updated_at": _now()}},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Project endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/{client_id}/projects", response_model=list[Project])
|
|
async def list_projects(
|
|
client_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
await _get_client_or_404(client_id, db)
|
|
await _assert_client_access(current_user, client_id, db)
|
|
docs = await db.projects.find({"client_id": client_id}).to_list(None)
|
|
return [_project_from_doc(d) for d in docs]
|
|
|
|
|
|
@router.post("/{client_id}/projects", response_model=Project)
|
|
async def create_project(
|
|
client_id: str,
|
|
body: ProjectCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
await _get_client_or_404(client_id, db)
|
|
await _assert_pm_or_admin(current_user, client_id, db)
|
|
now = _now()
|
|
project_id = str(ObjectId())
|
|
await db.projects.insert_one({
|
|
"_id": project_id,
|
|
"name": body.name,
|
|
"client_id": client_id,
|
|
"is_active": True,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
})
|
|
doc = await db.projects.find_one({"_id": project_id})
|
|
return _project_from_doc(doc)
|
|
|
|
|
|
@router.patch("/{client_id}/projects/{project_id}", response_model=Project)
|
|
async def update_project(
|
|
client_id: str,
|
|
project_id: str,
|
|
body: ProjectUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
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 = {k: v for k, v in body.model_dump(exclude_none=True).items()}
|
|
if not update:
|
|
raise HTTPException(status_code=422, detail="No fields to update")
|
|
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)
|
|
|
|
|
|
@router.delete("/{client_id}/projects/{project_id}", status_code=204)
|
|
async def archive_project(
|
|
client_id: str,
|
|
project_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
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)
|
|
await db.projects.update_one(
|
|
{"_id": project_id},
|
|
{"$set": {"is_active": False, "updated_at": _now()}},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal access-check helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _assert_pm_or_admin(user: User, client_id: str, db: AsyncIOMotorDatabase) -> None:
|
|
if user.role == UserRole.ADMIN:
|
|
return
|
|
# Check memberships collection (Phase 3)
|
|
mem = await db.memberships.find_one({"user_id": str(user.id), "organization_id": client_id})
|
|
if mem and mem.get("role_in_org") in ("owner", "admin", "manager"):
|
|
return
|
|
# Legacy fallback
|
|
if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []):
|
|
return
|
|
raise HTTPException(status_code=403, detail="Not a manager for this client")
|
|
|
|
|
|
async def _assert_client_access(user: User, client_id: str, db: AsyncIOMotorDatabase) -> None:
|
|
"""Allow platform staff, org members (any role), or PM of the client."""
|
|
if user.role in (UserRole.ADMIN, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.LINGUIST):
|
|
return
|
|
# Any org membership grants read access
|
|
mem = await db.memberships.find_one({"user_id": str(user.id), "organization_id": client_id})
|
|
if mem:
|
|
return
|
|
# Legacy fallback for pre-migration users
|
|
if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []):
|
|
return
|
|
if user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
|
|
return
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|