112 lines
4.1 KiB
Python
112 lines
4.1 KiB
Python
"""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",
|
|
)
|