api.py 15 KB

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