You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
depot_tools/git_auth.py

315 lines
12 KiB
Python

# 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)