api.py 14 KB

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