"""Auth dependencies + role-based access control. Every protected endpoint depends on `current_user`. Client endpoints derive `org_id` from the authenticated session's OrgMember record — never trust org_id from the body. """ from dataclasses import dataclass from typing import Optional from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from app.core.security import decode_token from app.models import User, OrgMember, Organization from app.models.enums import ProductAdminRole, ClientRole, SubscriptionStatus oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False) @dataclass class Principal: user: User org: Optional[Organization] member: Optional[OrgMember] client_role: Optional[ClientRole] admin_role: Optional[ProductAdminRole] @property def is_product_admin(self) -> bool: return self.user.is_product_admin async def current_user(token: str = Depends(oauth2_scheme)) -> User: if not token: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated") payload = decode_token(token) if not payload or payload.get("type") != "access": raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token") user = await User.get(payload["sub"]) if not user or user.disabled: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or disabled") return user async def current_principal(user: User = Depends(current_user)) -> Principal: """Resolve org context for the current user. Picks the user's membership (preferring an active one, but accepting any non-disabled membership) so org context is consistent with /me's has_org. """ members = await OrgMember.find(OrgMember.user_id == str(user.id)).to_list() # prefer active, then anything not explicitly disabled member = next((m for m in members if m.status == "active"), None) \ or next((m for m in members if m.status != "disabled"), None) org = None if member: try: org = await Organization.get(member.org_id) except Exception: org = None return Principal( user=user, org=org, member=member, client_role=member.role if member else None, admin_role=user.product_admin_role if user.is_product_admin else None, ) # ---------- guards ---------- async def require_org(p: Principal = Depends(current_principal)) -> Principal: """Ensure the caller has an organization context (any client role). Returns a clean 403 instead of letting org-scoped routes crash with a 500 when a Product Admin (who has no OrgMember) hits a client endpoint. """ if not p.org or not p.client_role: raise HTTPException( status.HTTP_403_FORBIDDEN, "This account has no organization. Use the Super Admin Panel.", ) return p def require_client_roles(*roles: ClientRole): async def _guard(p: Principal = Depends(current_principal)) -> Principal: if not p.org or not p.client_role: raise HTTPException(status.HTTP_403_FORBIDDEN, "No organization context") if roles and p.client_role not in roles: raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient role") return p return _guard def require_admin_roles(*roles: ProductAdminRole): async def _guard(p: Principal = Depends(current_principal)) -> Principal: if not p.is_product_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Product admin only") if roles and p.admin_role not in roles: raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient admin role") return p return _guard def require_write_access(p: Principal): """Block writes when org is in grace/archived/deleted state.""" if not p.org: raise HTTPException(status.HTTP_403_FORBIDDEN, "No organization") if p.org.subscription_status != SubscriptionStatus.active: raise HTTPException( status.HTTP_403_FORBIDDEN, f"Organization is {p.org.subscription_status.value} — read-only", )