api.py 15 KB

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