git_auth.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. # Copyright (c) 2024 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. """Defines utilities for setting up Git authentication."""
  5. from __future__ import annotations
  6. import enum
  7. import functools
  8. import logging
  9. import os
  10. from typing import TYPE_CHECKING, Callable
  11. import urllib.parse
  12. import gerrit_util
  13. import newauth
  14. import scm
  15. if TYPE_CHECKING:
  16. # Causes import cycle if imported normally
  17. import git_cl
  18. class ConfigMode(enum.Enum):
  19. """Modes to pass to ConfigChanger"""
  20. NO_AUTH = 1
  21. NEW_AUTH = 2
  22. NEW_AUTH_SSO = 3
  23. class ConfigChanger(object):
  24. """Changes Git auth config as needed for Gerrit."""
  25. # Can be used to determine whether this version of the config has
  26. # been applied to a Git repo.
  27. #
  28. # Increment this when making changes to the config, so that reliant
  29. # code can determine whether the config needs to be re-applied.
  30. VERSION: int = 4
  31. def __init__(
  32. self,
  33. *,
  34. mode: ConfigMode,
  35. remote_url: str,
  36. set_config_func: Callable[..., None] = scm.GIT.SetConfig,
  37. ):
  38. """Create a new ConfigChanger.
  39. Args:
  40. mode: How to configure auth
  41. remote_url: Git repository's remote URL, e.g.,
  42. https://chromium.googlesource.com/chromium/tools/depot_tools.git
  43. set_config_func: Function used to set configuration. Used
  44. for testing.
  45. """
  46. self.mode: ConfigMode = mode
  47. self._remote_url: str = remote_url
  48. self._set_config_func: Callable[..., None] = set_config_func
  49. @functools.cached_property
  50. def _shortname(self) -> str:
  51. # Example: chromium
  52. parts: urllib.parse.SplitResult = urllib.parse.urlsplit(
  53. self._remote_url)
  54. return _url_shortname(parts)
  55. @functools.cached_property
  56. def _base_url(self) -> str:
  57. # Example: https://chromium.googlesource.com/
  58. # Example: https://chromium-review.googlesource.com/
  59. parts: urllib.parse.SplitResult = urllib.parse.urlsplit(
  60. self._remote_url)
  61. return parts._replace(path='/', query='', fragment='').geturl()
  62. @classmethod
  63. def new_from_env(cls, cwd: str, cl: git_cl.Changelist) -> ConfigChanger:
  64. """Create a ConfigChanger by inferring from env.
  65. The Gerrit host is inferred from the current repo/branch.
  66. The user, which is used to determine the mode, is inferred using
  67. git-config(1) in the given `cwd`.
  68. """
  69. # This is determined either from the branch or repo config.
  70. #
  71. # Example: chromium-review.googlesource.com
  72. gerrit_host = cl.GetGerritHost()
  73. # This depends on what the user set for their remote.
  74. # There are a couple potential variations for the same host+repo.
  75. #
  76. # Example:
  77. # https://chromium.googlesource.com/chromium/tools/depot_tools.git
  78. remote_url = cl.GetRemoteUrl()
  79. if gerrit_host is None or remote_url is None:
  80. raise Exception(
  81. 'Error Git auth settings inferring from environment:'
  82. f' {gerrit_host=} {remote_url=}')
  83. assert gerrit_host is not None
  84. assert remote_url is not None
  85. return cls(
  86. mode=cls._infer_mode(cwd, gerrit_host),
  87. remote_url=remote_url,
  88. )
  89. @classmethod
  90. def new_for_remote(cls, cwd: str, remote_url: str) -> ConfigChanger:
  91. """Create a ConfigChanger for the given Gerrit host.
  92. The user, which is used to determine the mode, is inferred using
  93. git-config(1) in the given `cwd`.
  94. """
  95. c = cls(
  96. mode=ConfigMode.NEW_AUTH,
  97. remote_url=remote_url,
  98. )
  99. assert c._shortname, "Short name is empty"
  100. c.mode = cls._infer_mode(cwd, c._shortname + '-review.googlesource.com')
  101. return c
  102. @staticmethod
  103. def _infer_mode(cwd: str, gerrit_host: str) -> ConfigMode:
  104. """Infer default mode to use."""
  105. if not newauth.Enabled():
  106. return ConfigMode.NO_AUTH
  107. email: str = scm.GIT.GetConfig(cwd, 'user.email') or ''
  108. if gerrit_util.ShouldUseSSO(gerrit_host, email):
  109. return ConfigMode.NEW_AUTH_SSO
  110. if not gerrit_util.GitCredsAuthenticator.gerrit_account_exists(
  111. gerrit_host):
  112. return ConfigMode.NO_AUTH
  113. return ConfigMode.NEW_AUTH
  114. def apply(self, cwd: str) -> None:
  115. """Apply config changes to the Git repo directory."""
  116. self._apply_cred_helper(cwd)
  117. self._apply_sso(cwd)
  118. self._apply_gitcookies(cwd)
  119. def apply_global(self, cwd: str) -> None:
  120. """Apply config changes to the global (user) Git config.
  121. This will make the instance's mode (e.g., SSO or not) the global
  122. default for the Gerrit host, if not overridden by a specific Git repo.
  123. """
  124. self._apply_global_cred_helper(cwd)
  125. self._apply_global_sso(cwd)
  126. def _apply_cred_helper(self, cwd: str) -> None:
  127. """Apply config changes relating to credential helper."""
  128. cred_key: str = f'credential.{self._base_url}.helper'
  129. if self.mode == ConfigMode.NEW_AUTH:
  130. self._set_config(cwd, cred_key, '', modify_all=True)
  131. self._set_config(cwd, cred_key, 'luci', append=True)
  132. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  133. self._set_config(cwd, cred_key, None, modify_all=True)
  134. elif self.mode == ConfigMode.NO_AUTH:
  135. self._set_config(cwd, cred_key, None, modify_all=True)
  136. else:
  137. raise TypeError(f'Invalid mode {self.mode!r}')
  138. def _apply_sso(self, cwd: str) -> None:
  139. """Apply config changes relating to SSO."""
  140. sso_key: str = f'url.sso://{self._shortname}/.insteadOf'
  141. http_key: str = f'url.{self._remote_url}.insteadOf'
  142. if self.mode == ConfigMode.NEW_AUTH:
  143. self._set_config(cwd, 'protocol.sso.allow', None)
  144. self._set_config(cwd, sso_key, None, modify_all=True)
  145. # Shadow a potential global SSO rewrite rule.
  146. self._set_config(cwd, http_key, self._remote_url, modify_all=True)
  147. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  148. self._set_config(cwd, 'protocol.sso.allow', 'always')
  149. self._set_config(cwd, sso_key, self._base_url, modify_all=True)
  150. self._set_config(cwd, http_key, None, modify_all=True)
  151. elif self.mode == ConfigMode.NO_AUTH:
  152. self._set_config(cwd, 'protocol.sso.allow', None)
  153. self._set_config(cwd, sso_key, None, modify_all=True)
  154. self._set_config(cwd, http_key, None, modify_all=True)
  155. else:
  156. raise TypeError(f'Invalid mode {self.mode!r}')
  157. def _apply_gitcookies(self, cwd: str) -> None:
  158. """Apply config changes relating to gitcookies."""
  159. if self.mode == ConfigMode.NEW_AUTH:
  160. # Override potential global setting
  161. self._set_config(cwd, 'http.cookieFile', '', modify_all=True)
  162. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  163. # Override potential global setting
  164. self._set_config(cwd, 'http.cookieFile', '', modify_all=True)
  165. elif self.mode == ConfigMode.NO_AUTH:
  166. self._set_config(cwd, 'http.cookieFile', None, modify_all=True)
  167. else:
  168. raise TypeError(f'Invalid mode {self.mode!r}')
  169. def _apply_global_cred_helper(self, cwd: str) -> None:
  170. """Apply config changes relating to credential helper."""
  171. cred_key: str = f'credential.{self._base_url}.helper'
  172. if self.mode == ConfigMode.NEW_AUTH:
  173. self._set_config(cwd, cred_key, '', scope='global', modify_all=True)
  174. self._set_config(cwd, cred_key, 'luci', scope='global', append=True)
  175. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  176. # Avoid editing the user's config in case they manually
  177. # configured something.
  178. pass
  179. elif self.mode == ConfigMode.NO_AUTH:
  180. # Avoid editing the user's config in case they manually
  181. # configured something.
  182. pass
  183. else:
  184. raise TypeError(f'Invalid mode {self.mode!r}')
  185. def _apply_global_sso(self, cwd: str) -> None:
  186. """Apply config changes relating to SSO."""
  187. sso_key: str = f'url.sso://{self._shortname}/.insteadOf'
  188. if self.mode == ConfigMode.NEW_AUTH:
  189. # Do not unset protocol.sso.allow because it may be used by
  190. # other hosts.
  191. self._set_config(cwd,
  192. sso_key,
  193. None,
  194. scope='global',
  195. modify_all=True)
  196. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  197. self._set_config(cwd,
  198. 'protocol.sso.allow',
  199. 'always',
  200. scope='global')
  201. self._set_config(cwd,
  202. sso_key,
  203. self._base_url,
  204. scope='global',
  205. modify_all=True)
  206. elif self.mode == ConfigMode.NO_AUTH:
  207. # Avoid editing the user's config in case they manually
  208. # configured something.
  209. pass
  210. else:
  211. raise TypeError(f'Invalid mode {self.mode!r}')
  212. def _set_config(self, *args, **kwargs) -> None:
  213. self._set_config_func(*args, **kwargs)
  214. def AutoConfigure(cwd: str, cl: git_cl.Changelist) -> None:
  215. """Configure Git authentication automatically.
  216. This tracks when the config that has already been applied and skips
  217. doing anything if so.
  218. This may modify the global Git config and the local repo config as
  219. needed.
  220. """
  221. latestVer: int = ConfigChanger.VERSION
  222. v: int = 0
  223. try:
  224. v = int(
  225. scm.GIT.GetConfig(cwd, 'depot-tools.gitauthautoconfigured') or '0')
  226. except ValueError:
  227. v = 0
  228. if v < latestVer:
  229. logging.debug(
  230. 'Automatically configuring Git repo authentication'
  231. ' (current version: %r, latest: %r)', v, latestVer)
  232. Configure(cwd, cl)
  233. scm.GIT.SetConfig(cwd, 'depot-tools.gitAuthAutoConfigured',
  234. str(latestVer))
  235. def Configure(cwd: str, cl: git_cl.Changelist) -> None:
  236. """Configure Git authentication.
  237. This may modify the global Git config and the local repo config as
  238. needed.
  239. """
  240. logging.debug('Configuring Git authentication...')
  241. logging.debug('Configuring global Git authentication...')
  242. # We want the user's global config.
  243. # We can probably assume the root directory doesn't have any local
  244. # Git configuration.
  245. c = ConfigChanger.new_from_env('/', cl)
  246. c.apply_global(os.path.expanduser('~'))
  247. c2 = ConfigChanger.new_from_env(cwd, cl)
  248. if c2.mode == c.mode:
  249. logging.debug(
  250. 'Local user wants same mode %s as global;'
  251. ' clearing local repo auth config', c2.mode)
  252. c2.mode = ConfigMode.NO_AUTH
  253. c2.apply(cwd)
  254. return
  255. logging.debug('Local user wants mode %s while global user wants mode %s',
  256. c2.mode, c.mode)
  257. logging.debug('Configuring current Git repo authentication...')
  258. c2.apply(cwd)
  259. def ConfigureGlobal(cwd: str, remote_url: str) -> None:
  260. """Configure global/user Git authentication."""
  261. logging.debug('Configuring global Git authentication for %s', remote_url)
  262. if remote_url.startswith('file://'):
  263. return
  264. ConfigChanger.new_for_remote(cwd, remote_url).apply_global(cwd)
  265. def ClearRepoConfig(cwd: str, cl: git_cl.Changelist) -> None:
  266. """Clear the current Git repo authentication."""
  267. logging.debug('Clearing current Git repo authentication...')
  268. c = ConfigChanger.new_from_env(cwd, cl)
  269. c.mode = ConfigMode.NO_AUTH
  270. c.apply(cwd)
  271. def _url_shortname(parts: urllib.parse.SplitResult) -> str:
  272. """Format URL as Gerrit host shortname.
  273. Example: chromium
  274. """
  275. name: str = parts.netloc.split('.')[0]
  276. if name.endswith('-review'):
  277. name = name[:-len('-review')]
  278. return name