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>
160 lines
5.2 KiB
Python
160 lines
5.2 KiB
Python
from typing import Optional
|
|
|
|
from fastapi import Depends, HTTPException, Request, status
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
|
|
from ..models.user import User, UserRole
|
|
from .config import settings
|
|
from .database import get_database
|
|
from .security import decode_token
|
|
|
|
security = HTTPBearer()
|
|
|
|
# Roles that see all jobs (no tenant isolation)
|
|
STAFF_ROLES = {UserRole.ADMIN, UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION}
|
|
|
|
|
|
async def get_current_user(
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
) -> User:
|
|
token = credentials.credentials
|
|
payload = decode_token(token)
|
|
user_id: str = payload.get("sub")
|
|
|
|
if user_id is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Could not validate credentials",
|
|
)
|
|
|
|
user_doc = await db.users.find_one({"_id": user_id})
|
|
if user_doc is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found",
|
|
)
|
|
|
|
return User(**user_doc)
|
|
|
|
|
|
def require_role(required_role: UserRole):
|
|
async def role_checker(current_user: User = Depends(get_current_user)) -> User:
|
|
if current_user.role != required_role and current_user.role != UserRole.ADMIN:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions",
|
|
)
|
|
return current_user
|
|
|
|
return role_checker
|
|
|
|
|
|
def require_roles(*required_roles: UserRole):
|
|
async def roles_checker(current_user: User = Depends(get_current_user)) -> User:
|
|
if current_user.role not in required_roles and current_user.role != UserRole.ADMIN:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions",
|
|
)
|
|
return current_user
|
|
|
|
return roles_checker
|
|
|
|
|
|
async def get_current_user_optional(
|
|
request: Request,
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
) -> Optional[User]:
|
|
authorization: str = request.headers.get("Authorization")
|
|
if not authorization:
|
|
return None
|
|
|
|
try:
|
|
scheme, token = authorization.split()
|
|
if scheme.lower() != "bearer":
|
|
return None
|
|
|
|
payload = decode_token(token)
|
|
user_id: str = payload.get("sub")
|
|
|
|
if user_id is None:
|
|
return None
|
|
|
|
user_doc = await db.users.find_one({"_id": user_id})
|
|
if user_doc is None:
|
|
return None
|
|
|
|
return User(**user_doc)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def get_accessible_project_ids(
|
|
user: User,
|
|
db: AsyncIOMotorDatabase,
|
|
) -> Optional[list[str]]:
|
|
"""
|
|
Returns project IDs the user may access, or None meaning "see everything".
|
|
|
|
- Staff / Admin → None (unrestricted)
|
|
- Otherwise → projects in orgs where the user holds any membership
|
|
(falls back to legacy pm_client_ids/team lookups if no memberships found)
|
|
"""
|
|
if user.role in STAFF_ROLES:
|
|
return None
|
|
|
|
# Primary path: use memberships collection (Phase 3 SaaS)
|
|
user_id = str(user.id)
|
|
membership_cursor = db.memberships.find({"user_id": user_id}, {"organization_id": 1})
|
|
org_ids = [doc["organization_id"] async for doc in membership_cursor]
|
|
|
|
if org_ids:
|
|
projects = await db.projects.find(
|
|
{"client_id": {"$in": org_ids}, "is_active": True},
|
|
{"_id": 1},
|
|
).to_list(None)
|
|
return [str(p["_id"]) for p in projects]
|
|
|
|
# Legacy fallback (pre-backfill) — keeps the app working before migration runs
|
|
if user.role == UserRole.PROJECT_MANAGER:
|
|
client_ids = user.pm_client_ids or []
|
|
if not client_ids:
|
|
return []
|
|
projects = await db.projects.find(
|
|
{"client_id": {"$in": client_ids}, "is_active": True},
|
|
{"_id": 1},
|
|
).to_list(None)
|
|
return [str(p["_id"]) for p in projects]
|
|
|
|
teams = await db.teams.find(
|
|
{"member_user_ids": user_id},
|
|
{"client_id": 1},
|
|
).to_list(None)
|
|
client_ids = list({t["client_id"] for t in teams})
|
|
if not client_ids:
|
|
return []
|
|
projects = await db.projects.find(
|
|
{"client_id": {"$in": client_ids}, "is_active": True},
|
|
{"_id": 1},
|
|
).to_list(None)
|
|
return [str(p["_id"]) for p in projects]
|
|
|
|
|
|
def require_pm_for_client(client_id_param: str = "client_id"):
|
|
"""Dependency: ensures the current user is an Admin or PM for the given client."""
|
|
async def checker(
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
) -> User:
|
|
if current_user.role == UserRole.ADMIN:
|
|
return current_user
|
|
if current_user.role != UserRole.PROJECT_MANAGER:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
client_id = request.path_params.get(client_id_param)
|
|
if client_id not in (current_user.pm_client_ids or []):
|
|
raise HTTPException(status_code=403, detail="Not a manager for this client")
|
|
return current_user
|
|
|
|
return checker
|