# Copyright (c) 2024 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Defines utilities for setting up Git authentication.""" from __future__ import annotations import enum import functools import logging import os from typing import TYPE_CHECKING, Callable import urllib.parse import gerrit_util import newauth import scm if TYPE_CHECKING: # Causes import cycle if imported normally import git_cl class ConfigMode(enum.Enum): """Modes to pass to ConfigChanger""" NO_AUTH = 1 NEW_AUTH = 2 NEW_AUTH_SSO = 3 class ConfigChanger(object): """Changes Git auth config as needed for Gerrit.""" # Can be used to determine whether this version of the config has # been applied to a Git repo. # # Increment this when making changes to the config, so that reliant # code can determine whether the config needs to be re-applied. VERSION: int = 4 def __init__( self, *, mode: ConfigMode, remote_url: str, set_config_func: Callable[..., None] = scm.GIT.SetConfig, ): """Create a new ConfigChanger. Args: mode: How to configure auth remote_url: Git repository's remote URL, e.g., https://chromium.googlesource.com/chromium/tools/depot_tools.git set_config_func: Function used to set configuration. Used for testing. """ self.mode: ConfigMode = mode self._remote_url: str = remote_url self._set_config_func: Callable[..., None] = set_config_func @functools.cached_property def _shortname(self) -> str: # Example: chromium parts: urllib.parse.SplitResult = urllib.parse.urlsplit( self._remote_url) name: str = parts.netloc.split('.')[0] if name.endswith('-review'): name = name[:-len('-review')] return name @functools.cached_property def _base_url(self) -> str: # Example: https://chromium.googlesource.com/ # Example: https://chromium-review.googlesource.com/ parts: urllib.parse.SplitResult = urllib.parse.urlsplit( self._remote_url) return parts._replace(path='/', query='', fragment='').geturl() @classmethod def new_from_env(cls, cwd: str, cl: git_cl.Changelist) -> ConfigChanger: """Create a ConfigChanger by inferring from env. The Gerrit host is inferred from the current repo/branch. The user, which is used to determine the mode, is inferred using git-config(1) in the given `cwd`. """ # This is determined either from the branch or repo config. # # Example: chromium-review.googlesource.com gerrit_host = cl.GetGerritHost() # This depends on what the user set for their remote. # There are a couple potential variations for the same host+repo. # # Example: # https://chromium.googlesource.com/chromium/tools/depot_tools.git remote_url = cl.GetRemoteUrl() if gerrit_host is None or remote_url is None: raise Exception( 'Error Git auth settings inferring from environment:' f' {gerrit_host=} {remote_url=}') assert gerrit_host is not None assert remote_url is not None return cls( mode=cls._infer_mode(cwd, gerrit_host), remote_url=remote_url, ) @classmethod def new_for_remote(cls, cwd: str, remote_url: str) -> ConfigChanger: """Create a ConfigChanger for the given Gerrit host. The user, which is used to determine the mode, is inferred using git-config(1) in the given `cwd`. """ c = cls( mode=ConfigMode.NEW_AUTH, remote_url=remote_url, ) c.mode = cls._infer_mode(cwd, c._shortname + '-review.googlesource.com') return c @staticmethod def _infer_mode(cwd: str, gerrit_host: str) -> ConfigMode: """Infer default mode to use.""" if not newauth.Enabled(): return ConfigMode.NO_AUTH email: str = scm.GIT.GetConfig(cwd, 'user.email') or '' if gerrit_util.ShouldUseSSO(gerrit_host, email): return ConfigMode.NEW_AUTH_SSO if not gerrit_util.GitCredsAuthenticator.gerrit_account_exists( gerrit_host): return ConfigMode.NO_AUTH return ConfigMode.NEW_AUTH def apply(self, cwd: str) -> None: """Apply config changes to the Git repo directory.""" self._apply_cred_helper(cwd) self._apply_sso(cwd) self._apply_gitcookies(cwd) def apply_global(self, cwd: str) -> None: """Apply config changes to the global (user) Git config. This will make the instance's mode (e.g., SSO or not) the global default for the Gerrit host, if not overridden by a specific Git repo. """ self._apply_global_cred_helper(cwd) self._apply_global_sso(cwd) def _apply_cred_helper(self, cwd: str) -> None: """Apply config changes relating to credential helper.""" cred_key: str = f'credential.{self._base_url}.helper' if self.mode == ConfigMode.NEW_AUTH: self._set_config(cwd, cred_key, '', modify_all=True) self._set_config(cwd, cred_key, 'luci', append=True) elif self.mode == ConfigMode.NEW_AUTH_SSO: self._set_config(cwd, cred_key, None, modify_all=True) elif self.mode == ConfigMode.NO_AUTH: self._set_config(cwd, cred_key, None, modify_all=True) else: raise TypeError(f'Invalid mode {self.mode!r}') def _apply_sso(self, cwd: str) -> None: """Apply config changes relating to SSO.""" sso_key: str = f'url.sso://{self._shortname}/.insteadOf' http_key: str = f'url.{self._remote_url}.insteadOf' if self.mode == ConfigMode.NEW_AUTH: self._set_config(cwd, 'protocol.sso.allow', None) self._set_config(cwd, sso_key, None, modify_all=True) # Shadow a potential global SSO rewrite rule. self._set_config(cwd, http_key, self._remote_url, modify_all=True) elif self.mode == ConfigMode.NEW_AUTH_SSO: self._set_config(cwd, 'protocol.sso.allow', 'always') self._set_config(cwd, sso_key, self._base_url, modify_all=True) self._set_config(cwd, http_key, None, modify_all=True) elif self.mode == ConfigMode.NO_AUTH: self._set_config(cwd, 'protocol.sso.allow', None) self._set_config(cwd, sso_key, None, modify_all=True) self._set_config(cwd, http_key, None, modify_all=True) else: raise TypeError(f'Invalid mode {self.mode!r}') def _apply_gitcookies(self, cwd: str) -> None: """Apply config changes relating to gitcookies.""" if self.mode == ConfigMode.NEW_AUTH: # Override potential global setting self._set_config(cwd, 'http.cookieFile', '', modify_all=True) elif self.mode == ConfigMode.NEW_AUTH_SSO: # Override potential global setting self._set_config(cwd, 'http.cookieFile', '', modify_all=True) elif self.mode == ConfigMode.NO_AUTH: self._set_config(cwd, 'http.cookieFile', None, modify_all=True) else: raise TypeError(f'Invalid mode {self.mode!r}') def _apply_global_cred_helper(self, cwd: str) -> None: """Apply config changes relating to credential helper.""" cred_key: str = f'credential.{self._base_url}.helper' if self.mode == ConfigMode.NEW_AUTH: self._set_config(cwd, cred_key, '', scope='global', modify_all=True) self._set_config(cwd, cred_key, 'luci', scope='global', append=True) elif self.mode == ConfigMode.NEW_AUTH_SSO: # Avoid editing the user's config in case they manually # configured something. pass elif self.mode == ConfigMode.NO_AUTH: # Avoid editing the user's config in case they manually # configured something. pass else: raise TypeError(f'Invalid mode {self.mode!r}') def _apply_global_sso(self, cwd: str) -> None: """Apply config changes relating to SSO.""" sso_key: str = f'url.sso://{self._shortname}/.insteadOf' if self.mode == ConfigMode.NEW_AUTH: # Do not unset protocol.sso.allow because it may be used by # other hosts. self._set_config(cwd, sso_key, None, scope='global', modify_all=True) elif self.mode == ConfigMode.NEW_AUTH_SSO: self._set_config(cwd, 'protocol.sso.allow', 'always', scope='global') self._set_config(cwd, sso_key, self._base_url, scope='global', modify_all=True) elif self.mode == ConfigMode.NO_AUTH: # Avoid editing the user's config in case they manually # configured something. pass else: raise TypeError(f'Invalid mode {self.mode!r}') def _set_config(self, *args, **kwargs) -> None: self._set_config_func(*args, **kwargs) def AutoConfigure(cwd: str, cl: git_cl.Changelist) -> None: """Configure Git authentication automatically. This tracks when the config that has already been applied and skips doing anything if so. This may modify the global Git config and the local repo config as needed. """ latestVer: int = ConfigChanger.VERSION v: int = 0 try: v = int( scm.GIT.GetConfig(cwd, 'depot-tools.gitauthautoconfigured') or '0') except ValueError: v = 0 if v < latestVer: logging.debug( 'Automatically configuring Git repo authentication' ' (current version: %r, latest: %r)', v, latestVer) Configure(cwd, cl) scm.GIT.SetConfig(cwd, 'depot-tools.gitAuthAutoConfigured', str(latestVer)) def Configure(cwd: str, cl: git_cl.Changelist) -> None: """Configure Git authentication. This may modify the global Git config and the local repo config as needed. """ logging.debug('Configuring Git authentication...') logging.debug('Configuring global Git authentication...') # We want the user's global config. # We can probably assume the root directory doesn't have any local # Git configuration. c = ConfigChanger.new_from_env('/', cl) c.apply_global(os.path.expanduser('~')) c2 = ConfigChanger.new_from_env(cwd, cl) if c2.mode == c.mode: logging.debug( 'Local user wants same mode %s as global;' ' clearing local repo auth config', c2.mode) c2.mode = ConfigMode.NO_AUTH c2.apply(cwd) return logging.debug('Local user wants mode %s while global user wants mode %s', c2.mode, c.mode) logging.debug('Configuring current Git repo authentication...') c2.apply(cwd) def ConfigureGlobal(cwd: str, remote_url: str) -> None: """Configure global/user Git authentication.""" logging.debug('Configuring global Git authentication for %s', remote_url) ConfigChanger.new_for_remote(cwd, remote_url).apply_global(cwd) def ClearRepoConfig(cwd: str, cl: git_cl.Changelist) -> None: """Clear the current Git repo authentication.""" logging.debug('Clearing current Git repo authentication...') c = ConfigChanger.new_from_env(cwd, cl) c.mode = ConfigMode.NO_AUTH c.apply(cwd)