api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. from asgiref.sync import sync_to_async
  2. from django.contrib.auth import aget_user
  3. from django.http import HttpRequest, HttpResponse
  4. from django.shortcuts import aget_object_or_404
  5. from ninja import Router
  6. from ninja.errors import HttpError
  7. from ninja.pagination import paginate
  8. from organizations.backends import invitation_backend
  9. from organizations.signals import owner_changed, user_added
  10. from apps.teams.models import Team
  11. from apps.teams.schema import OrganizationDetailSchema
  12. from apps.users.models import User
  13. from apps.users.utils import ais_user_registration_open
  14. from glitchtip.api.authentication import AuthHttpRequest
  15. from glitchtip.api.permissions import has_permission
  16. from .invitation_backend import InvitationTokenGenerator
  17. from .models import (
  18. Organization,
  19. OrganizationOwner,
  20. OrganizationUser,
  21. OrganizationUserRole,
  22. )
  23. from .queryset_utils import get_organization_users_queryset, get_organizations_queryset
  24. from .schema import (
  25. AcceptInviteIn,
  26. AcceptInviteSchema,
  27. OrganizationInSchema,
  28. OrganizationSchema,
  29. OrganizationUserDetailSchema,
  30. OrganizationUserIn,
  31. OrganizationUserSchema,
  32. OrganizationUserUpdateSchema,
  33. )
  34. from .utils import is_organization_creation_open
  35. router = Router()
  36. """
  37. GET /api/0/organizations/
  38. POST /api/0/organizations/ (Not in sentry)
  39. GET /api/0/organizations/{organization_slug}/
  40. PUT /api/0/organizations/{organization_slug}/
  41. DELETE /api/0/organizations/{organization_slug}/ (Not in sentry)
  42. GET /api/0/organizations/{organization_slug}/members/
  43. GET /api/0/organizations/{organization_slug}/members/{member_id}/
  44. POST /api/0/organizations/{organization_slug}/members/{member_id}/
  45. DELETE /api/0/organizations/{organization_slug}/members/{member_id}/
  46. GET /api/0/teams/{organization_slug}/{team_slug}/members/ (Not documented in sentry)
  47. """
  48. @router.get("organizations/", response=list[OrganizationSchema], by_alias=True)
  49. @paginate
  50. @has_permission(["org:read", "org:write", "org:admin"])
  51. async def list_organizations(
  52. request: AuthHttpRequest,
  53. response: HttpResponse,
  54. owner: bool | None = None,
  55. query: str | None = None,
  56. sortBy: str | None = None,
  57. ):
  58. """Return list of all organizations the user has access to."""
  59. return get_organizations_queryset(request.auth.user_id).order_by("name")
  60. @router.get(
  61. "organizations/{slug:organization_slug}/",
  62. response=OrganizationDetailSchema,
  63. by_alias=True,
  64. )
  65. @has_permission(["org:read", "org:write", "org:admin"])
  66. async def get_organization(request: AuthHttpRequest, organization_slug: str):
  67. """Return Organization with project and team details."""
  68. return await aget_object_or_404(
  69. get_organizations_queryset(request.auth.user_id, add_details=True),
  70. slug=organization_slug,
  71. )
  72. @router.post("organizations/", response={201: OrganizationDetailSchema}, by_alias=True)
  73. @has_permission(["org:write", "org:admin"])
  74. async def create_organization(request: AuthHttpRequest, payload: OrganizationInSchema):
  75. """
  76. Create new organization
  77. The first organization on a server is always allowed to be created.
  78. Afterwards, ENABLE_OPEN_USER_REGISTRATION is checked.
  79. Superusers are always allowed to create organizations.
  80. """
  81. user = await aget_object_or_404(User, id=request.auth.user_id)
  82. if not await is_organization_creation_open() and not user.is_superuser:
  83. raise HttpError(403, "Organization creation is not open")
  84. organization = await Organization.objects.acreate(**payload.dict())
  85. org_user = await organization._org_user_model.objects.acreate(
  86. user=user, organization=organization, role=OrganizationUserRole.OWNER
  87. )
  88. await organization._org_owner_model.objects.acreate(
  89. organization=organization, organization_user=org_user
  90. )
  91. user_added.send(sender=organization, user=user)
  92. return 201, await get_organizations_queryset(user.id, add_details=True).aget(
  93. id=organization.id
  94. )
  95. @router.put(
  96. "organizations/{slug:organization_slug}/",
  97. response=OrganizationDetailSchema,
  98. by_alias=True,
  99. )
  100. @has_permission(["org:write", "org:admin"])
  101. async def update_organization(
  102. request: AuthHttpRequest, organization_slug: str, payload: OrganizationInSchema
  103. ):
  104. """Update an organization."""
  105. organization = await aget_object_or_404(
  106. get_organizations_queryset(
  107. request.auth.user_id,
  108. role_required=True,
  109. add_details=True,
  110. ),
  111. slug=organization_slug,
  112. )
  113. if organization.actor_role < OrganizationUserRole.MANAGER:
  114. raise HttpError(403, "forbidden")
  115. for attr, value in payload.dict().items():
  116. setattr(organization, attr, value)
  117. await organization.asave()
  118. return organization
  119. @router.delete(
  120. "organizations/{slug:organization_slug}/",
  121. response={204: None},
  122. )
  123. @has_permission(["org:admin"])
  124. async def delete_organization(request: AuthHttpRequest, organization_slug: str):
  125. organization = await aget_object_or_404(
  126. get_organizations_queryset(request.auth.user_id, role_required=True),
  127. slug=organization_slug,
  128. )
  129. if organization.actor_role < OrganizationUserRole.MANAGER:
  130. raise HttpError(403, "forbidden")
  131. await organization.adelete()
  132. return 204, None
  133. @router.get(
  134. "organizations/{slug:organization_slug}/members/",
  135. response=list[OrganizationUserSchema],
  136. by_alias=True,
  137. )
  138. @paginate
  139. @has_permission(["member:read", "member:write", "member:admin"])
  140. async def list_organization_members(
  141. request: AuthHttpRequest, response: HttpResponse, organization_slug: str
  142. ):
  143. return get_organization_users_queryset(request.auth.user_id, organization_slug)
  144. @router.get(
  145. "teams/{slug:organization_slug}/{slug:team_slug}/members/",
  146. response=list[OrganizationUserSchema],
  147. by_alias=True,
  148. )
  149. @paginate
  150. @has_permission(["member:read", "member:write", "member:admin"])
  151. async def list_team_organization_members(
  152. request: AuthHttpRequest,
  153. response: HttpResponse,
  154. organization_slug: str,
  155. team_slug: str,
  156. ):
  157. return get_organization_users_queryset(
  158. request.auth.user_id, organization_slug, team_slug=team_slug
  159. )
  160. @router.get(
  161. "organizations/{slug:organization_slug}/members/{int:member_id}/",
  162. response=OrganizationUserDetailSchema,
  163. by_alias=True,
  164. )
  165. @has_permission(["member:read", "member:write", "member:admin"])
  166. async def get_organization_member(
  167. request: AuthHttpRequest, organization_slug: str, member_id: int
  168. ):
  169. user_id = request.auth.user_id
  170. return await aget_object_or_404(
  171. get_organization_users_queryset(user_id, organization_slug, add_details=True),
  172. pk=member_id,
  173. )
  174. @router.post(
  175. "organizations/{slug:organization_slug}/members/",
  176. response={201: OrganizationUserSchema},
  177. by_alias=True,
  178. )
  179. @has_permission(["member:write", "member:admin"])
  180. async def create_organization_member(
  181. request: AuthHttpRequest, organization_slug: str, payload: OrganizationUserIn
  182. ):
  183. user_id = request.auth.user_id
  184. organization = await aget_object_or_404(
  185. get_organizations_queryset(user_id, role_required=True)
  186. .filter(organization_users__user=user_id)
  187. .prefetch_related("organization_users"),
  188. slug=organization_slug,
  189. )
  190. if organization.actor_role < OrganizationUserRole.MANAGER:
  191. raise HttpError(403, "forbidden")
  192. email = payload.email
  193. if (
  194. not await ais_user_registration_open()
  195. and not await User.objects.filter(email=email).aexists()
  196. ):
  197. raise HttpError(403, "Only existing users may be invited")
  198. if await organization.organization_users.filter(user__email=email).aexists():
  199. raise HttpError(
  200. 409,
  201. f"The user {email} is already a member",
  202. )
  203. member, created = await OrganizationUser.objects.aget_or_create(
  204. email=email,
  205. organization=organization,
  206. defaults={"role": OrganizationUserRole.from_string(payload.org_role)},
  207. )
  208. if not created and not payload.reinvite:
  209. raise HttpError(
  210. 409,
  211. f"The user {email} is already invited",
  212. )
  213. teams = [
  214. team
  215. async for team in Team.objects.filter(
  216. slug__in=[role.team_slug for role in payload.team_roles],
  217. organization=organization,
  218. ).values_list("pk", flat=True)
  219. ]
  220. if teams:
  221. await member.teams.aadd(*teams)
  222. await sync_to_async(invitation_backend().send_invitation)(member)
  223. return 201, member
  224. @router.delete(
  225. "organizations/{slug:organization_slug}/members/{int:member_id}/",
  226. response={204: None},
  227. )
  228. @has_permission(["member:admin"])
  229. async def delete_organization_member(
  230. request: AuthHttpRequest, organization_slug: str, member_id: int
  231. ):
  232. """Remove member (user) from organization"""
  233. user_id = request.auth.user_id
  234. if await OrganizationOwner.objects.filter(
  235. organization_user__user_id=user_id,
  236. organization__slug=organization_slug,
  237. organization_user__id=member_id,
  238. ).aexists():
  239. raise HttpError(400, "User is organization owner. Transfer ownership first.")
  240. org_user = await aget_object_or_404(
  241. get_organization_users_queryset(user_id, organization_slug, role_required=True),
  242. id=member_id,
  243. )
  244. if org_user.actor_role < OrganizationUserRole.MANAGER:
  245. raise HttpError(403, "Forbidden")
  246. await org_user.adelete()
  247. return 204, None
  248. @router.put(
  249. "organizations/{slug:organization_slug}/members/{int:member_id}/",
  250. response=OrganizationUserDetailSchema,
  251. by_alias=True,
  252. )
  253. @has_permission(["member:write", "member:admin"])
  254. async def update_organization_member(
  255. request: AuthHttpRequest,
  256. organization_slug: str,
  257. member_id: int,
  258. payload: OrganizationUserUpdateSchema,
  259. ):
  260. """Update member role within organization"""
  261. member = await aget_object_or_404(
  262. get_organization_users_queryset(
  263. request.auth.user_id,
  264. organization_slug,
  265. role_required=True,
  266. add_details=True,
  267. ).select_related("organization"),
  268. id=member_id,
  269. )
  270. if member.actor_role < OrganizationUserRole.MANAGER:
  271. raise HttpError(403, "Forbidden")
  272. member.role = OrganizationUserRole.from_string(payload.org_role)
  273. await member.asave()
  274. return member
  275. @router.post(
  276. "organizations/{slug:organization_slug}/members/{int:member_id}/set_owner/",
  277. response=OrganizationUserDetailSchema,
  278. by_alias=True,
  279. )
  280. @has_permission(["member:admin"])
  281. async def set_organization_owner(
  282. request: AuthHttpRequest, organization_slug: str, member_id: int
  283. ):
  284. """
  285. Set this team member as the one and only one Organization owner
  286. Only an existing Owner or user with the "org:admin" scope is able to perform this.
  287. GlitchTip specific API, no sentry api compatibility
  288. """
  289. user_id = request.auth.user_id
  290. new_owner = await aget_object_or_404(
  291. get_organization_users_queryset(
  292. user_id, organization_slug, add_details=True
  293. ).select_related("organization__owner__organization_user"),
  294. id=member_id,
  295. )
  296. organization = new_owner.organization
  297. old_owner = organization.owner.organization_user
  298. if not (
  299. old_owner.pk is user_id
  300. or await organization.organization_users.filter(
  301. user=user_id, role=OrganizationUserRole.OWNER
  302. ).aexists()
  303. ):
  304. raise HttpError(403, "Only owner may set organization owner.")
  305. organization.owner.organization_user = new_owner
  306. await organization.owner.asave()
  307. owner_changed.send(sender=organization, old=old_owner, new=new_owner)
  308. return new_owner
  309. async def validate_token(org_user_id: int, token: str) -> OrganizationUser:
  310. """Validate invite token and return org user"""
  311. org_user = await aget_object_or_404(
  312. OrganizationUser.objects.all()
  313. .select_related("organization", "user")
  314. .prefetch_related("user__socialaccount_set"),
  315. pk=org_user_id,
  316. )
  317. if not InvitationTokenGenerator().check_token(org_user, token):
  318. raise HttpError(403, "Invalid invite token")
  319. return org_user
  320. @router.get(
  321. "accept/{int:org_user_id}/{str:token}/",
  322. response=AcceptInviteSchema,
  323. by_alias=True,
  324. auth=None,
  325. )
  326. async def get_accept_invite(request: HttpRequest, org_user_id: int, token: str):
  327. """Return relevant organization data around an invite"""
  328. org_user = await validate_token(org_user_id, token)
  329. return {"accept_invite": False, "org_user": org_user}
  330. @router.post(
  331. "accept/{int:org_user_id}/{str:token}/",
  332. response=AcceptInviteSchema,
  333. by_alias=True,
  334. )
  335. async def accept_invite(
  336. request: AuthHttpRequest, org_user_id: int, token: str, payload: AcceptInviteIn
  337. ):
  338. """Accepts invite to organization"""
  339. org_user = await validate_token(org_user_id, token)
  340. if payload.accept_invite:
  341. org_user.user = await aget_user(request)
  342. org_user.email = None
  343. await org_user.asave()
  344. org_user = (
  345. await OrganizationUser.objects.filter(pk=org_user.pk)
  346. .select_related("organization", "user")
  347. .prefetch_related("user__socialaccount_set")
  348. .aget()
  349. )
  350. return {"accept_invite": payload.accept_invite, "org_user": org_user}