PageRenderTime 54ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 0ms

/src/sentry/integrations/bitbucket_server/integration.py

https://github.com/getsentry/sentry
Python | 364 lines | 359 code | 5 blank | 0 comment | 3 complexity | d24fdde2411331573e01c5fdb01f785f MD5 | raw file
Possible License(s): Apache-2.0, BSD-3-Clause
  1. import logging
  2. from urllib.parse import urlparse
  3. from cryptography.hazmat.backends import default_backend
  4. from cryptography.hazmat.primitives.serialization import load_pem_private_key
  5. from django import forms
  6. from django.core.validators import URLValidator
  7. from django.utils.translation import ugettext_lazy as _
  8. from django.views.decorators.csrf import csrf_exempt
  9. from rest_framework.request import Request
  10. from rest_framework.response import Response
  11. from sentry.integrations import (
  12. FeatureDescription,
  13. IntegrationFeatures,
  14. IntegrationInstallation,
  15. IntegrationMetadata,
  16. IntegrationProvider,
  17. )
  18. from sentry.integrations.mixins import RepositoryMixin
  19. from sentry.models import Identity, Repository
  20. from sentry.pipeline import PipelineView
  21. from sentry.shared_integrations.exceptions import ApiError, IntegrationError
  22. from sentry.tasks.integrations import migrate_repo
  23. from sentry.utils.compat import filter
  24. from sentry.web.helpers import render_to_response
  25. from .client import BitbucketServer, BitbucketServerSetupClient
  26. from .repository import BitbucketServerRepositoryProvider
  27. logger = logging.getLogger("sentry.integrations.bitbucket_server")
  28. DESCRIPTION = """
  29. Connect your Sentry organization to Bitbucket Server, enabling the following features:
  30. """
  31. FEATURES = [
  32. FeatureDescription(
  33. """
  34. Track commits and releases (learn more
  35. [here](https://docs.sentry.io/learn/releases/))
  36. """,
  37. IntegrationFeatures.COMMITS,
  38. ),
  39. FeatureDescription(
  40. """
  41. Resolve Sentry issues via Bitbucket Server commits by
  42. including `Fixes PROJ-ID` in the message
  43. """,
  44. IntegrationFeatures.COMMITS,
  45. ),
  46. ]
  47. setup_alert = {
  48. "type": "warning",
  49. "icon": "icon-warning-sm",
  50. "text": "Your Bitbucket Server instance must be able to communicate with Sentry."
  51. " Sentry makes outbound requests from a [static set of IP"
  52. " addresses](https://docs.sentry.io/ip-ranges/) that you may wish"
  53. " to explicitly allow in your firewall to support this integration.",
  54. }
  55. metadata = IntegrationMetadata(
  56. description=DESCRIPTION.strip(),
  57. features=FEATURES,
  58. author="The Sentry Team",
  59. noun=_("Installation"),
  60. issue_url="https://github.com/getsentry/sentry/issues/new?assignees=&labels=Component:%20Integrations&template=bug.yml&title=Bitbucket%20Server%20Integration%20Problem",
  61. source_url="https://github.com/getsentry/sentry/tree/master/src/sentry/integrations/bitbucket_server",
  62. aspects={},
  63. )
  64. class InstallationForm(forms.Form):
  65. url = forms.CharField(
  66. label=_("Bitbucket URL"),
  67. help_text=_(
  68. "The base URL for your Bitbucket Server instance, including the host and protocol."
  69. ),
  70. widget=forms.TextInput(attrs={"placeholder": "https://bitbucket.example.com"}),
  71. validators=[URLValidator()],
  72. )
  73. verify_ssl = forms.BooleanField(
  74. label=_("Verify SSL"),
  75. help_text=_(
  76. "By default, we verify SSL certificates "
  77. "when making requests to your Bitbucket instance."
  78. ),
  79. widget=forms.CheckboxInput(),
  80. required=False,
  81. initial=True,
  82. )
  83. consumer_key = forms.CharField(
  84. label=_("Bitbucket Consumer Key"),
  85. widget=forms.TextInput(attrs={"placeholder": "sentry-consumer-key"}),
  86. )
  87. private_key = forms.CharField(
  88. label=_("Bitbucket Consumer Private Key"),
  89. widget=forms.Textarea(
  90. attrs={
  91. "placeholder": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
  92. }
  93. ),
  94. )
  95. def clean_url(self):
  96. """Strip off trailing / as they cause invalid URLs downstream"""
  97. return self.cleaned_data["url"].rstrip("/")
  98. def clean_private_key(self):
  99. data = self.cleaned_data["private_key"]
  100. try:
  101. load_pem_private_key(data.encode("utf-8"), None, default_backend())
  102. except Exception:
  103. raise forms.ValidationError(
  104. "Private key must be a valid SSH private key encoded in a PEM format."
  105. )
  106. return data
  107. def clean_consumer_key(self):
  108. data = self.cleaned_data["consumer_key"]
  109. if len(data) > 200:
  110. raise forms.ValidationError("Consumer key is limited to 200 characters.")
  111. return data
  112. class InstallationConfigView(PipelineView):
  113. """
  114. Collect the OAuth client credentials from the user.
  115. """
  116. def dispatch(self, request: Request, pipeline) -> Response:
  117. if request.method == "POST":
  118. form = InstallationForm(request.POST)
  119. if form.is_valid():
  120. form_data = form.cleaned_data
  121. pipeline.bind_state("installation_data", form_data)
  122. return pipeline.next_step()
  123. else:
  124. form = InstallationForm()
  125. return render_to_response(
  126. template="sentry/integrations/bitbucket-server-config.html",
  127. context={"form": form},
  128. request=request,
  129. )
  130. class OAuthLoginView(PipelineView):
  131. """
  132. Start the OAuth dance by creating a request token
  133. and redirecting the user to approve it.
  134. """
  135. @csrf_exempt
  136. def dispatch(self, request: Request, pipeline) -> Response:
  137. if "oauth_token" in request.GET:
  138. return pipeline.next_step()
  139. config = pipeline.fetch_state("installation_data")
  140. client = BitbucketServerSetupClient(
  141. config.get("url"),
  142. config.get("consumer_key"),
  143. config.get("private_key"),
  144. config.get("verify_ssl"),
  145. )
  146. try:
  147. request_token = client.get_request_token()
  148. except ApiError as error:
  149. logger.info(
  150. "identity.bitbucket-server.request-token",
  151. extra={"url": config.get("url"), "error": error},
  152. )
  153. return pipeline.error(f"Could not fetch a request token from Bitbucket. {error}")
  154. pipeline.bind_state("request_token", request_token)
  155. if not request_token.get("oauth_token"):
  156. logger.info(
  157. "identity.bitbucket-server.oauth-token",
  158. extra={"url": config.get("url")},
  159. )
  160. return pipeline.error("Missing oauth_token")
  161. authorize_url = client.get_authorize_url(request_token)
  162. return self.redirect(authorize_url)
  163. class OAuthCallbackView(PipelineView):
  164. """
  165. Complete the OAuth dance by exchanging our request token
  166. into an access token.
  167. """
  168. @csrf_exempt
  169. def dispatch(self, request: Request, pipeline) -> Response:
  170. config = pipeline.fetch_state("installation_data")
  171. client = BitbucketServerSetupClient(
  172. config.get("url"),
  173. config.get("consumer_key"),
  174. config.get("private_key"),
  175. config.get("verify_ssl"),
  176. )
  177. try:
  178. access_token = client.get_access_token(
  179. pipeline.fetch_state("request_token"), request.GET["oauth_token"]
  180. )
  181. pipeline.bind_state("access_token", access_token)
  182. return pipeline.next_step()
  183. except ApiError as error:
  184. logger.info("identity.bitbucket-server.access-token", extra={"error": error})
  185. return pipeline.error(f"Could not fetch an access token from Bitbucket. {str(error)}")
  186. class BitbucketServerIntegration(IntegrationInstallation, RepositoryMixin):
  187. """
  188. IntegrationInstallation implementation for Bitbucket Server
  189. """
  190. repo_search = True
  191. default_identity = None
  192. def get_client(self):
  193. if self.default_identity is None:
  194. try:
  195. self.default_identity = self.get_default_identity()
  196. except Identity.DoesNotExist:
  197. raise IntegrationError("Identity not found.")
  198. return BitbucketServer(
  199. self.model.metadata["base_url"],
  200. self.default_identity.data,
  201. self.model.metadata["verify_ssl"],
  202. )
  203. @property
  204. def username(self):
  205. return self.model.name
  206. def error_message_from_json(self, data):
  207. return data.get("error", {}).get("message", "unknown error")
  208. def get_repositories(self, query=None):
  209. if not query:
  210. resp = self.get_client().get_repos()
  211. return [
  212. {
  213. "identifier": repo["project"]["key"] + "/" + repo["slug"],
  214. "project": repo["project"]["key"],
  215. "repo": repo["slug"],
  216. "name": repo["project"]["name"] + "/" + repo["name"],
  217. }
  218. for repo in resp.get("values", [])
  219. ]
  220. full_query = (query).encode("utf-8")
  221. resp = self.get_client().search_repositories(full_query)
  222. return [
  223. {
  224. "identifier": repo["project"]["key"] + "/" + repo["slug"],
  225. "project": repo["project"]["key"],
  226. "repo": repo["slug"],
  227. "name": repo["project"]["name"] + "/" + repo["name"],
  228. }
  229. for repo in resp.get("values", [])
  230. ]
  231. def has_repo_access(self, repo):
  232. """
  233. We can assume user always has repo access, since the Bitbucket API is limiting the results based on the REPO_ADMIN permission
  234. """
  235. return True
  236. def get_unmigratable_repositories(self):
  237. repos = Repository.objects.filter(
  238. organization_id=self.organization_id, provider="bitbucket_server"
  239. )
  240. accessible_repos = [r["identifier"] for r in self.get_repositories()]
  241. return filter(lambda repo: repo.name not in accessible_repos, repos)
  242. def reinstall(self):
  243. self.reinstall_repositories()
  244. class BitbucketServerIntegrationProvider(IntegrationProvider):
  245. key = "bitbucket_server"
  246. name = "Bitbucket Server"
  247. metadata = metadata
  248. integration_cls = BitbucketServerIntegration
  249. needs_default_identity = True
  250. can_add = True
  251. features = frozenset([IntegrationFeatures.COMMITS])
  252. setup_dialog_config = {"width": 1030, "height": 1000}
  253. def get_pipeline_views(self):
  254. return [InstallationConfigView(), OAuthLoginView(), OAuthCallbackView()]
  255. def post_install(self, integration, organization, extra=None):
  256. repo_ids = Repository.objects.filter(
  257. organization_id=organization.id,
  258. provider__in=["bitbucket_server", "integrations:bitbucket_server"],
  259. integration_id__isnull=True,
  260. ).values_list("id", flat=True)
  261. for repo_id in repo_ids:
  262. migrate_repo.apply_async(
  263. kwargs={
  264. "repo_id": repo_id,
  265. "integration_id": integration.id,
  266. "organization_id": organization.id,
  267. }
  268. )
  269. def build_integration(self, state):
  270. install = state["installation_data"]
  271. access_token = state["access_token"]
  272. hostname = urlparse(install["url"]).netloc
  273. external_id = "{}:{}".format(hostname, install["consumer_key"])[:64]
  274. credentials = {
  275. "consumer_key": install["consumer_key"],
  276. "private_key": install["private_key"],
  277. "access_token": access_token["oauth_token"],
  278. "access_token_secret": access_token["oauth_token_secret"],
  279. }
  280. return {
  281. "name": install["consumer_key"],
  282. "provider": self.key,
  283. "external_id": external_id,
  284. "metadata": {
  285. "base_url": install["url"],
  286. "domain_name": hostname,
  287. "verify_ssl": install["verify_ssl"],
  288. },
  289. "user_identity": {
  290. "type": self.key,
  291. "external_id": external_id,
  292. "data": credentials,
  293. "scopes": [],
  294. },
  295. }
  296. def setup(self):
  297. from sentry.plugins.base import bindings
  298. bindings.add(
  299. "integration-repository.provider",
  300. BitbucketServerRepositoryProvider,
  301. id=f"integrations:{self.key}",
  302. )