PageRenderTime 51ms CodeModel.GetById 25ms RepoModel.GetById 1ms app.codeStats 0ms

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

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