Files
verify/backend/app/core/deps.py
Mohamed Mathar Irfan ed6610d5d8 Initial project upload
2026-07-28 17:57:02 +05:30

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",
)