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.
315 lines
12 KiB
Python
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)
|