deps.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import time
  2. from collections.abc import Generator
  3. from uuid import UUID
  4. import jwt
  5. from fastapi import Depends, HTTPException, Path, Query, status
  6. from fastapi.security import OAuth2PasswordBearer
  7. from fastapi.security.utils import get_authorization_scheme_param
  8. from sqlalchemy import exists, select
  9. from sqlalchemy.orm import Session
  10. from starlette.requests import Request
  11. from app.api.utils import get_project_organization_id
  12. from app.core import config, security
  13. from app.core.session import session
  14. from app.models import (
  15. GlobalRole,
  16. OrgRole,
  17. Project,
  18. User,
  19. UserOrganization,
  20. association_table_commission_member,
  21. )
  22. reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="auth/access-token")
  23. FULL_ACCESS_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
  24. def get_session() -> Generator[Session, None, None]:
  25. with session() as db:
  26. yield db
  27. async def get_current_user(
  28. session: Session = Depends(get_session), token: str = Depends(reusable_oauth2)
  29. ) -> User:
  30. try:
  31. payload = jwt.decode(token, config.settings.SECRET_KEY, algorithms=[security.JWT_ALGORITHM])
  32. except jwt.DecodeError:
  33. raise HTTPException(status.HTTP_403_FORBIDDEN, "Could not validate credentials.")
  34. token_data = security.JWTTokenPayload(**payload)
  35. if token_data.refresh:
  36. raise HTTPException(
  37. status.HTTP_403_FORBIDDEN, "Could not validate credentials, cannot use refresh token"
  38. )
  39. now = int(time.time())
  40. if now < token_data.issued_at or now > token_data.expires_at:
  41. raise HTTPException(
  42. status.HTTP_403_FORBIDDEN,
  43. "Could not validate credentials, token expired or not yet valid",
  44. )
  45. result = session.execute(select(User).where(User.id == token_data.sub))
  46. user = result.scalars().first()
  47. if not user:
  48. raise HTTPException(status_code=404, detail="User not found.")
  49. return user
  50. async def get_token_flexible(
  51. request: Request,
  52. token: str | None = Query(default=None),
  53. ) -> str:
  54. """Same as reusable_oauth2, but also accepts ?token=... in the query
  55. string — needed because native EventSource cannot set custom headers."""
  56. auth_header = request.headers.get("Authorization")
  57. if auth_header:
  58. scheme, param = get_authorization_scheme_param(auth_header)
  59. if scheme.lower() == "bearer" and param:
  60. return param
  61. if token:
  62. return token
  63. raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
  64. async def get_current_user_flexible(
  65. session: Session = Depends(get_session),
  66. token: str = Depends(get_token_flexible),
  67. ) -> User:
  68. # identical body to get_current_user, just sourcing `token` differently
  69. try:
  70. payload = jwt.decode(token, config.settings.SECRET_KEY, algorithms=[security.JWT_ALGORITHM])
  71. except jwt.DecodeError:
  72. raise HTTPException(status.HTTP_403_FORBIDDEN, "Could not validate credentials.")
  73. token_data = security.JWTTokenPayload(**payload)
  74. if token_data.refresh:
  75. raise HTTPException(
  76. status.HTTP_403_FORBIDDEN, "Could not validate credentials, cannot use refresh token"
  77. )
  78. now = int(time.time())
  79. if now < token_data.issued_at or now > token_data.expires_at:
  80. raise HTTPException(
  81. status.HTTP_403_FORBIDDEN,
  82. "Could not validate credentials, token expired or not yet valid",
  83. )
  84. result = session.execute(select(User).where(User.id == token_data.sub))
  85. user = result.scalars().first()
  86. if not user:
  87. raise HTTPException(status_code=404, detail="User not found.")
  88. return user
  89. def require_org_role_sse(*allowed_roles: OrgRole):
  90. def dependency(
  91. project_id: UUID = Path(...),
  92. session: Session = Depends(get_session),
  93. current_user: User = Depends(get_current_user_flexible),
  94. ) -> User:
  95. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  96. return current_user
  97. if not _has_org_role(session, current_user.id, project_id, *allowed_roles):
  98. get_project_organization_id(session, project_id)
  99. raise HTTPException(
  100. status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
  101. )
  102. return current_user
  103. return dependency
  104. def require_super_admin(current_user: User = Depends(get_current_user)) -> User:
  105. if current_user.global_role != GlobalRole.SUPER_ADMIN:
  106. raise HTTPException(status.HTTP_403_FORBIDDEN, "Requires super_admin")
  107. return current_user
  108. def _has_org_role(
  109. session: Session, user_id: UUID, project_id: UUID, *allowed_roles: OrgRole
  110. ) -> bool:
  111. """Single query: does user_id hold one of allowed_roles in the
  112. organization that owns project_id? Replaces loading the user's full
  113. organizations list and scanning it in Python."""
  114. return (
  115. session.execute(
  116. select(
  117. exists().where(
  118. UserOrganization.user_id == user_id,
  119. UserOrganization.role.in_(allowed_roles),
  120. UserOrganization.organization_id
  121. == (
  122. select(Project.organization_id)
  123. .where(Project.id == project_id)
  124. .scalar_subquery()
  125. ),
  126. )
  127. )
  128. ).scalar()
  129. is True
  130. )
  131. def _is_commission_member(session: Session, user_id: str, commission_id: str | None) -> bool:
  132. if commission_id is None:
  133. return False
  134. stmt = select(
  135. exists().where(
  136. association_table_commission_member.c.commission_id == commission_id,
  137. association_table_commission_member.c.user_id == user_id,
  138. )
  139. )
  140. return session.scalar(stmt)
  141. def require_org_role(*allowed_roles: OrgRole):
  142. def dependency(
  143. project_id: UUID = Path(...),
  144. session: Session = Depends(get_session),
  145. current_user: User = Depends(get_current_user),
  146. ) -> User:
  147. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  148. return current_user
  149. if not _has_org_role(session, current_user.id, project_id, *allowed_roles):
  150. get_project_organization_id(session, project_id) # raises 404 if project missing
  151. raise HTTPException(
  152. status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
  153. )
  154. return current_user
  155. return dependency
  156. def assert_commission_ownership(
  157. session: Session, current_user: User, project_id: UUID, commission_id: str | None
  158. ) -> None:
  159. """Called inside create/update handlers once the payload's commission_id
  160. is known. org_admin/respo_benevole: any commission is fine. respo_commission:
  161. must be a member of commission_id, which must be set."""
  162. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  163. return
  164. if _has_org_role(session, current_user.id, project_id, *FULL_ACCESS_ROLES):
  165. return
  166. if commission_id is None or not _is_commission_member(
  167. session, current_user.id, str(commission_id)
  168. ):
  169. raise HTTPException(403, "Cannot assign to a commission you are not a member of")
  170. def require_organization_role(*allowed_roles: OrgRole):
  171. """Authorize based on the caller's role within the organization named
  172. directly in the path (organization_id), rather than resolved through a
  173. project. Super admins always pass."""
  174. def dependency(
  175. organization_id: UUID = Path(...),
  176. current_user: User = Depends(get_current_user),
  177. ) -> User:
  178. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  179. return current_user
  180. role = next(
  181. (
  182. m.role
  183. for m in current_user.organizations
  184. if m.organization_id == str(organization_id)
  185. ),
  186. None,
  187. )
  188. if role is None or role not in allowed_roles:
  189. raise HTTPException(
  190. status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
  191. )
  192. return current_user
  193. return dependency