deps.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import time
  2. from collections.abc import Generator
  3. import jwt
  4. from fastapi import Depends, HTTPException, Path, status
  5. from fastapi.security import OAuth2PasswordBearer
  6. from sqlalchemy import UUID, exists, select
  7. from sqlalchemy.orm import Session
  8. from app.api.utils import get_project_organization_id
  9. from app.core import config, security
  10. from app.core.session import session
  11. from app.models import (
  12. CommissionMember,
  13. GlobalRole,
  14. OrgRole,
  15. Project,
  16. User,
  17. UserOrganization,
  18. )
  19. reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="auth/access-token")
  20. FULL_ACCESS_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
  21. def get_session() -> Generator[Session, None, None]:
  22. with session() as db:
  23. yield db
  24. async def get_current_user(
  25. session: Session = Depends(get_session), token: str = Depends(reusable_oauth2)
  26. ) -> User:
  27. try:
  28. payload = jwt.decode(token, config.settings.SECRET_KEY, algorithms=[security.JWT_ALGORITHM])
  29. except jwt.DecodeError:
  30. raise HTTPException(status.HTTP_403_FORBIDDEN, "Could not validate credentials.")
  31. token_data = security.JWTTokenPayload(**payload)
  32. if token_data.refresh:
  33. raise HTTPException(
  34. status.HTTP_403_FORBIDDEN, "Could not validate credentials, cannot use refresh token"
  35. )
  36. now = int(time.time())
  37. if now < token_data.issued_at or now > token_data.expires_at:
  38. raise HTTPException(
  39. status.HTTP_403_FORBIDDEN,
  40. "Could not validate credentials, token expired or not yet valid",
  41. )
  42. result = session.execute(select(User).where(User.id == token_data.sub))
  43. user = result.scalars().first()
  44. if not user:
  45. raise HTTPException(status_code=404, detail="User not found.")
  46. return user
  47. def require_super_admin(current_user: User = Depends(get_current_user)) -> User:
  48. if current_user.global_role != GlobalRole.SUPER_ADMIN:
  49. raise HTTPException(status.HTTP_403_FORBIDDEN, "Requires super_admin")
  50. return current_user
  51. def _has_org_role(
  52. session: Session, user_id: UUID, project_id: str, *allowed_roles: OrgRole
  53. ) -> bool:
  54. """Single query: does user_id hold one of allowed_roles in the
  55. organization that owns project_id? Replaces loading the user's full
  56. organizations list and scanning it in Python."""
  57. return (
  58. session.execute(
  59. select(
  60. exists().where(
  61. UserOrganization.user_id == user_id,
  62. UserOrganization.role.in_(allowed_roles),
  63. UserOrganization.organization_id
  64. == (
  65. select(Project.organization_id)
  66. .where(Project.id == project_id)
  67. .scalar_subquery()
  68. ),
  69. )
  70. )
  71. ).scalar()
  72. is True
  73. )
  74. def _is_commission_member(session: Session, user_id: str, commission_id: str | None) -> bool:
  75. if commission_id is None:
  76. return False
  77. return session.get(CommissionMember, (commission_id, user_id)) is not None
  78. def require_org_role(*allowed_roles: OrgRole):
  79. def dependency(
  80. project_id: str = Path(...),
  81. session: Session = Depends(get_session),
  82. current_user: User = Depends(get_current_user),
  83. ) -> User:
  84. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  85. return current_user
  86. if not _has_org_role(session, current_user.id, project_id, *allowed_roles):
  87. get_project_organization_id(session, project_id) # raises 404 if project missing
  88. raise HTTPException(
  89. status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
  90. )
  91. return current_user
  92. return dependency
  93. def require_commission_membership():
  94. """For CREATE endpoints (template/slot): confirms org membership only.
  95. The finer 'does this respo_commission own the commission_id in the
  96. payload' check happens inside the handler via assert_commission_ownership,
  97. since the dependency layer can't see the parsed body cheaply."""
  98. return require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
  99. def assert_commission_ownership(
  100. session: Session, current_user: User, project_id: str, commission_id: str | None
  101. ) -> None:
  102. """Called inside create/update handlers once the payload's commission_id
  103. is known. org_admin/respo_benevole: any commission is fine. respo_commission:
  104. must be a member of commission_id, which must be set."""
  105. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  106. return
  107. if _has_org_role(session, current_user.id, project_id, *FULL_ACCESS_ROLES):
  108. return
  109. if commission_id is None or not _is_commission_member(
  110. session, current_user.id, str(commission_id)
  111. ):
  112. raise HTTPException(403, "Cannot assign to a commission you are not a member of")
  113. def require_organization_role(*allowed_roles: OrgRole):
  114. """Authorize based on the caller's role within the organization named
  115. directly in the path (organization_id), rather than resolved through a
  116. project. Super admins always pass."""
  117. def dependency(
  118. organization_id: str = Path(...),
  119. current_user: User = Depends(get_current_user),
  120. ) -> User:
  121. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  122. return current_user
  123. role = next(
  124. (m.role for m in current_user.organizations if m.organization_id == organization_id),
  125. None,
  126. )
  127. if role is None or role not in allowed_roles:
  128. raise HTTPException(
  129. status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
  130. )
  131. return current_user
  132. return dependency