git_auth.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  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 = 2
  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. return ConfigMode.NEW_AUTH
  113. def apply(self, cwd: str) -> None:
  114. """Apply config changes to the Git repo directory."""
  115. self._apply_cred_helper(cwd)
  116. self._apply_sso(cwd)
  117. self._apply_gitcookies(cwd)
  118. def apply_global(self, cwd: str) -> None:
  119. """Apply config changes to the global (user) Git config.
  120. This will make the instance's mode (e.g., SSO or not) the global
  121. default for the Gerrit host, if not overridden by a specific Git repo.
  122. """
  123. self._apply_global_cred_helper(cwd)
  124. self._apply_global_sso(cwd)
  125. def _apply_cred_helper(self, cwd: str) -> None:
  126. """Apply config changes relating to credential helper."""
  127. cred_key: str = f'credential.{self._base_url}.helper'
  128. if self.mode == ConfigMode.NEW_AUTH:
  129. self._set_config(cwd, cred_key, '', modify_all=True)
  130. self._set_config(cwd, cred_key, 'luci', append=True)
  131. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  132. self._set_config(cwd, cred_key, None, modify_all=True)
  133. elif self.mode == ConfigMode.NO_AUTH:
  134. self._set_config(cwd, cred_key, None, modify_all=True)
  135. else:
  136. raise TypeError(f'Invalid mode {self.mode!r}')
  137. def _apply_sso(self, cwd: str) -> None:
  138. """Apply config changes relating to SSO."""
  139. sso_key: str = f'url.sso://{self._shortname}/.insteadOf'
  140. if self.mode == ConfigMode.NEW_AUTH:
  141. self._set_config(cwd, 'protocol.sso.allow', None)
  142. self._set_config(cwd, sso_key, None, modify_all=True)
  143. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  144. self._set_config(cwd, 'protocol.sso.allow', 'always')
  145. self._set_config(cwd, sso_key, self._base_url, modify_all=True)
  146. elif self.mode == ConfigMode.NO_AUTH:
  147. self._set_config(cwd, 'protocol.sso.allow', None)
  148. self._set_config(cwd, sso_key, None, modify_all=True)
  149. else:
  150. raise TypeError(f'Invalid mode {self.mode!r}')
  151. def _apply_gitcookies(self, cwd: str) -> None:
  152. """Apply config changes relating to gitcookies."""
  153. if self.mode == ConfigMode.NEW_AUTH:
  154. # Override potential global setting
  155. self._set_config(cwd, 'http.cookieFile', '', modify_all=True)
  156. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  157. # Override potential global setting
  158. self._set_config(cwd, 'http.cookieFile', '', modify_all=True)
  159. elif self.mode == ConfigMode.NO_AUTH:
  160. self._set_config(cwd, 'http.cookieFile', None, modify_all=True)
  161. else:
  162. raise TypeError(f'Invalid mode {self.mode!r}')
  163. def _apply_global_cred_helper(self, cwd: str) -> None:
  164. """Apply config changes relating to credential helper."""
  165. cred_key: str = f'credential.{self._base_url}.helper'
  166. if self.mode == ConfigMode.NEW_AUTH:
  167. self._set_config(cwd, cred_key, '', scope='global', modify_all=True)
  168. self._set_config(cwd, cred_key, 'luci', scope='global', append=True)
  169. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  170. # Avoid editing the user's config in case they manually
  171. # configured something.
  172. pass
  173. elif self.mode == ConfigMode.NO_AUTH:
  174. # Avoid editing the user's config in case they manually
  175. # configured something.
  176. pass
  177. else:
  178. raise TypeError(f'Invalid mode {self.mode!r}')
  179. def _apply_global_sso(self, cwd: str) -> None:
  180. """Apply config changes relating to SSO."""
  181. sso_key: str = f'url.sso://{self._shortname}/.insteadOf'
  182. if self.mode == ConfigMode.NEW_AUTH:
  183. # Do not unset protocol.sso.allow because it may be used by
  184. # other hosts.
  185. self._set_config(cwd,
  186. sso_key,
  187. None,
  188. scope='global',
  189. modify_all=True)
  190. elif self.mode == ConfigMode.NEW_AUTH_SSO:
  191. self._set_config(cwd,
  192. 'protocol.sso.allow',
  193. 'always',
  194. scope='global')
  195. self._set_config(cwd,
  196. sso_key,
  197. self._base_url,
  198. scope='global',
  199. modify_all=True)
  200. elif self.mode == ConfigMode.NO_AUTH:
  201. # Avoid editing the user's config in case they manually
  202. # configured something.
  203. pass
  204. else:
  205. raise TypeError(f'Invalid mode {self.mode!r}')
  206. def _set_config(self, *args, **kwargs) -> None:
  207. self._set_config_func(*args, **kwargs)
  208. def AutoConfigure(cwd: str, cl: git_cl.Changelist) -> None:
  209. """Configure Git authentication automatically.
  210. This tracks when the config that has already been applied and skips
  211. doing anything if so.
  212. This may modify the global Git config and the local repo config as
  213. needed.
  214. """
  215. latestVer: int = ConfigChanger.VERSION
  216. v: int = 0
  217. try:
  218. v = int(
  219. scm.GIT.GetConfig(cwd, 'depot-tools.gitauthautoconfigured') or '0')
  220. except ValueError:
  221. v = 0
  222. if v < latestVer:
  223. logging.debug(
  224. 'Automatically configuring Git repo authentication'
  225. ' (current version: %r, latest: %r)', v, latestVer)
  226. Configure(cwd, cl)
  227. scm.GIT.SetConfig(cwd, 'depot-tools.gitAuthAutoConfigured',
  228. str(latestVer))
  229. def Configure(cwd: str, cl: git_cl.Changelist) -> None:
  230. """Configure Git authentication.
  231. This may modify the global Git config and the local repo config as
  232. needed.
  233. """
  234. logging.debug('Configuring Git authentication...')
  235. logging.debug('Configuring global Git authentication...')
  236. # We want the user's global config.
  237. # We can probably assume the root directory doesn't have any local
  238. # Git configuration.
  239. c = ConfigChanger.new_from_env('/', cl)
  240. c.apply_global(os.path.expanduser('~'))
  241. c2 = ConfigChanger.new_from_env(cwd, cl)
  242. if c2.mode == c.mode:
  243. logging.debug(
  244. 'Local user wants same mode %s as global;'
  245. ' clearing local repo auth config', c2.mode)
  246. c2.mode = ConfigMode.NO_AUTH
  247. c2.apply(cwd)
  248. return
  249. logging.debug('Local user wants mode %s while global user wants mode %s',
  250. c2.mode, c.mode)
  251. logging.debug('Configuring current Git repo authentication...')
  252. c2.apply(cwd)
  253. def ConfigureGlobal(cwd: str, remote_url: str) -> None:
  254. """Configure global/user Git authentication."""
  255. logging.debug('Configuring global Git authentication for %s', remote_url)
  256. ConfigChanger.new_for_remote(cwd, remote_url).apply_global(cwd)
  257. def ClearRepoConfig(cwd: str, cl: git_cl.Changelist) -> None:
  258. """Clear the current Git repo authentication."""
  259. logging.debug('Clearing current Git repo authentication...')
  260. c = ConfigChanger.new_from_env(cwd, cl)
  261. c.mode = ConfigMode.NO_AUTH
  262. c.apply(cwd)