git_auth.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  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. name: str = parts.netloc.split('.')[0]
  55. if name.endswith('-review'):
  56. name = name[:-len('-review')]
  57. return name
  58. @functools.cached_property
  59. def _base_url(self) -> str:
  60. # Example: https://chromium.googlesource.com/
  61. # Example: https://chromium-review.googlesource.com/
  62. parts: urllib.parse.SplitResult = urllib.parse.urlsplit(
  63. self._remote_url)
  64. return parts._replace(path='/', query='', fragment='').geturl()
  65. @classmethod
  66. def new_from_env(cls, cwd: str, cl: git_cl.Changelist) -> ConfigChanger:
  67. """Create a ConfigChanger by inferring from env.
  68. The Gerrit host is inferred from the current repo/branch.
  69. The user, which is used to determine the mode, is inferred using
  70. git-config(1) in the given `cwd`.
  71. """
  72. # This is determined either from the branch or repo config.
  73. #
  74. # Example: chromium-review.googlesource.com
  75. gerrit_host = cl.GetGerritHost()
  76. # This depends on what the user set for their remote.
  77. # There are a couple potential variations for the same host+repo.
  78. #
  79. # Example:
  80. # https://chromium.googlesource.com/chromium/tools/depot_tools.git
  81. remote_url = cl.GetRemoteUrl()
  82. if gerrit_host is None or remote_url is None:
  83. raise Exception(
  84. 'Error Git auth settings inferring from environment:'
  85. f' {gerrit_host=} {remote_url=}')
  86. assert gerrit_host is not None
  87. assert remote_url is not None
  88. return cls(
  89. mode=cls._infer_mode(cwd, gerrit_host),
  90. remote_url=remote_url,
  91. )
  92. @classmethod
  93. def new_for_remote(cls, cwd: str, remote_url: str) -> ConfigChanger:
  94. """Create a ConfigChanger for the given Gerrit host.
  95. The user, which is used to determine the mode, is inferred using
  96. git-config(1) in the given `cwd`.
  97. """
  98. c = cls(
  99. mode=ConfigMode.NEW_AUTH,
  100. remote_url=remote_url,
  101. )
  102. c.mode = cls._infer_mode(cwd, c._shortname + '-review.googlesource.com')
  103. return c
  104. @staticmethod
  105. def _infer_mode(cwd: str, gerrit_host: str) -> ConfigMode:
  106. """Infer default mode to use."""
  107. if not newauth.Enabled():
  108. return ConfigMode.NO_AUTH
  109. email: str = scm.GIT.GetConfig(cwd, 'user.email') or ''
  110. if gerrit_util.ShouldUseSSO(gerrit_host, email):
  111. return ConfigMode.NEW_AUTH_SSO
  112. if not gerrit_util.GitCredsAuthenticator.gerrit_account_exists(
  113. gerrit_host):
  114. return ConfigMode.NO_AUTH
  115. return ConfigMode.NEW_AUTH
  116. def apply(self, cwd: str) -> None:
  117. """Apply config changes to the Git repo directory."""
  118. self._apply_cred_helper(cwd)
  119. self._apply_sso(cwd)
  120. self._apply_gitcookies(cwd)
  121. def apply_global(self, cwd: str) -> None:
  122. """Apply config changes to the global (user) Git config.
  123. This will make the instance's mode (e.g., SSO or not) the global
  124. default for the Gerrit host, if not overridden by a specific Git repo.
  125. """
  126. self._apply_global_cred_helper(cwd)
  127. self._apply_global_sso(cwd)
  128. def _apply_cred_helper(self, cwd: str) -> None:
  129. """Apply config changes relating to credential helper."""
  130. cred_key: str = f'credential.{self._base_url}.helper'
  131. if self.mode == ConfigMode.NEW_AUTH:
  132. self._set_config(cwd, cred_key, '', modify_all=True)
  133. self._set_config(cwd, cred_key, 'luci', append=True)
  134. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  135. self._set_config(cwd, cred_key, None, modify_all=True)
  136. elif self.mode == ConfigMode.NO_AUTH:
  137. self._set_config(cwd, cred_key, None, modify_all=True)
  138. else:
  139. raise TypeError(f'Invalid mode {self.mode!r}')
  140. def _apply_sso(self, cwd: str) -> None:
  141. """Apply config changes relating to SSO."""
  142. sso_key: str = f'url.sso://{self._shortname}/.insteadOf'
  143. http_key: str = f'url.{self._remote_url}.insteadOf'
  144. if self.mode == ConfigMode.NEW_AUTH:
  145. self._set_config(cwd, 'protocol.sso.allow', None)
  146. self._set_config(cwd, sso_key, None, modify_all=True)
  147. # Shadow a potential global SSO rewrite rule.
  148. self._set_config(cwd, http_key, self._remote_url, modify_all=True)
  149. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  150. self._set_config(cwd, 'protocol.sso.allow', 'always')
  151. self._set_config(cwd, sso_key, self._base_url, modify_all=True)
  152. self._set_config(cwd, http_key, None, modify_all=True)
  153. elif self.mode == ConfigMode.NO_AUTH:
  154. self._set_config(cwd, 'protocol.sso.allow', None)
  155. self._set_config(cwd, sso_key, None, modify_all=True)
  156. self._set_config(cwd, http_key, None, modify_all=True)
  157. else:
  158. raise TypeError(f'Invalid mode {self.mode!r}')
  159. def _apply_gitcookies(self, cwd: str) -> None:
  160. """Apply config changes relating to gitcookies."""
  161. if self.mode == ConfigMode.NEW_AUTH:
  162. # Override potential global setting
  163. self._set_config(cwd, 'http.cookieFile', '', modify_all=True)
  164. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  165. # Override potential global setting
  166. self._set_config(cwd, 'http.cookieFile', '', modify_all=True)
  167. elif self.mode == ConfigMode.NO_AUTH:
  168. self._set_config(cwd, 'http.cookieFile', None, modify_all=True)
  169. else:
  170. raise TypeError(f'Invalid mode {self.mode!r}')
  171. def _apply_global_cred_helper(self, cwd: str) -> None:
  172. """Apply config changes relating to credential helper."""
  173. cred_key: str = f'credential.{self._base_url}.helper'
  174. if self.mode == ConfigMode.NEW_AUTH:
  175. self._set_config(cwd, cred_key, '', scope='global', modify_all=True)
  176. self._set_config(cwd, cred_key, 'luci', scope='global', append=True)
  177. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  178. # Avoid editing the user's config in case they manually
  179. # configured something.
  180. pass
  181. elif self.mode == ConfigMode.NO_AUTH:
  182. # Avoid editing the user's config in case they manually
  183. # configured something.
  184. pass
  185. else:
  186. raise TypeError(f'Invalid mode {self.mode!r}')
  187. def _apply_global_sso(self, cwd: str) -> None:
  188. """Apply config changes relating to SSO."""
  189. sso_key: str = f'url.sso://{self._shortname}/.insteadOf'
  190. if self.mode == ConfigMode.NEW_AUTH:
  191. # Do not unset protocol.sso.allow because it may be used by
  192. # other hosts.
  193. self._set_config(cwd,
  194. sso_key,
  195. None,
  196. scope='global',
  197. modify_all=True)
  198. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  199. self._set_config(cwd,
  200. 'protocol.sso.allow',
  201. 'always',
  202. scope='global')
  203. self._set_config(cwd,
  204. sso_key,
  205. self._base_url,
  206. scope='global',
  207. modify_all=True)
  208. elif self.mode == ConfigMode.NO_AUTH:
  209. # Avoid editing the user's config in case they manually
  210. # configured something.
  211. pass
  212. else:
  213. raise TypeError(f'Invalid mode {self.mode!r}')
  214. def _set_config(self, *args, **kwargs) -> None:
  215. self._set_config_func(*args, **kwargs)
  216. def AutoConfigure(cwd: str, cl: git_cl.Changelist) -> None:
  217. """Configure Git authentication automatically.
  218. This tracks when the config that has already been applied and skips
  219. doing anything if so.
  220. This may modify the global Git config and the local repo config as
  221. needed.
  222. """
  223. latestVer: int = ConfigChanger.VERSION
  224. v: int = 0
  225. try:
  226. v = int(
  227. scm.GIT.GetConfig(cwd, 'depot-tools.gitauthautoconfigured') or '0')
  228. except ValueError:
  229. v = 0
  230. if v < latestVer:
  231. logging.debug(
  232. 'Automatically configuring Git repo authentication'
  233. ' (current version: %r, latest: %r)', v, latestVer)
  234. Configure(cwd, cl)
  235. scm.GIT.SetConfig(cwd, 'depot-tools.gitAuthAutoConfigured',
  236. str(latestVer))
  237. def Configure(cwd: str, cl: git_cl.Changelist) -> None:
  238. """Configure Git authentication.
  239. This may modify the global Git config and the local repo config as
  240. needed.
  241. """
  242. logging.debug('Configuring Git authentication...')
  243. logging.debug('Configuring global Git authentication...')
  244. # We want the user's global config.
  245. # We can probably assume the root directory doesn't have any local
  246. # Git configuration.
  247. c = ConfigChanger.new_from_env('/', cl)
  248. c.apply_global(os.path.expanduser('~'))
  249. c2 = ConfigChanger.new_from_env(cwd, cl)
  250. if c2.mode == c.mode:
  251. logging.debug(
  252. 'Local user wants same mode %s as global;'
  253. ' clearing local repo auth config', c2.mode)
  254. c2.mode = ConfigMode.NO_AUTH
  255. c2.apply(cwd)
  256. return
  257. logging.debug('Local user wants mode %s while global user wants mode %s',
  258. c2.mode, c.mode)
  259. logging.debug('Configuring current Git repo authentication...')
  260. c2.apply(cwd)
  261. def ConfigureGlobal(cwd: str, remote_url: str) -> None:
  262. """Configure global/user Git authentication."""
  263. logging.debug('Configuring global Git authentication for %s', remote_url)
  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)