deps.py 5.8 KB

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