Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion providers/git/docs/connections/git.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ Username or Access Token name (optional)

Access Token (optional)
The access token for HTTPS authentication. When provided along with the username,
the hook injects the credentials into the repository URL for HTTPS cloning.
the hook injects the credentials into the repository URL for HTTPS cloning. With Git 2.46
or newer, the hook also enables URL-scoped ``http.proactiveAuth`` so public repositories
authenticate the initial request instead of using anonymous rate limits. Older Git versions,
or installations whose version cannot be detected, retain challenge-based authentication and
emit a warning. Proactive Basic authentication is not enabled for plaintext HTTP connections.

Extra (optional)
Specify the extra parameters as a JSON dictionary. The following keys are supported:
Expand Down
8 changes: 5 additions & 3 deletions providers/git/src/airflow/providers/git/bundles/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,9 +359,11 @@ def _has_version(repo: Repo, version: str) -> bool:

def _fetch_bare_repo(self):
refspecs = ["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"]
cm = nullcontext()
if self.hook and (cmd := self.hook.env.get("GIT_SSH_COMMAND")):
cm = self.bare_repo.git.custom_environment(GIT_SSH_COMMAND=cmd)
cm = (
self.bare_repo.git.custom_environment(**self.hook.env)
if self.hook and self.hook.env
else nullcontext()
)
with cm:
self.bare_repo.remotes.origin.fetch(refspecs)
self.bare_repo.close()
Expand Down
40 changes: 40 additions & 0 deletions providers/git/src/airflow/providers/git/hooks/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@
import stat
import tempfile
import warnings
from functools import lru_cache
from typing import Any
from urllib.parse import quote as urlquote

from git import Git
from git.exc import GitCommandError

from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.compat.sdk import AirflowException, BaseHook

Expand Down Expand Up @@ -131,6 +135,15 @@ def __init__(

_VALID_STRICT_HOST_KEY_CHECKING = frozenset({"yes", "no", "accept-new", "off", "ask"})
_SSH_REPO_URL_PATTERN = re.compile(r"^[^/@:]+@[^/:]+:")
_MIN_PROACTIVE_AUTH_GIT_VERSION = (2, 46)

@staticmethod
@lru_cache(maxsize=1)
def _get_git_version_info() -> tuple[int, ...] | None:
try:
return Git().version_info
except (GitCommandError, OSError, ValueError):
return None

def _uses_ssh_transport_options(self) -> bool:
# Heuristic: any SSH-specific option implies SSH; otherwise fall back to the URL scheme.
Expand Down Expand Up @@ -187,9 +200,11 @@ def _process_git_auth_url(self):
if not isinstance(self.repo_url, str):
return
if self.auth_token and self.repo_url.startswith("https://"):
original_url = self.repo_url
encoded_user = urlquote(self.user_name, safe="")
encoded_token = urlquote(self.auth_token, safe="")
self.repo_url = self.repo_url.replace("https://", f"https://{encoded_user}:{encoded_token}@", 1)
self._configure_proactive_http_auth(original_url)
elif self.auth_token and self.repo_url.startswith("http://"):
encoded_user = urlquote(self.user_name, safe="")
encoded_token = urlquote(self.auth_token, safe="")
Expand All @@ -200,6 +215,31 @@ def _process_git_auth_url(self):
elif not self.repo_url.startswith("git@") and not self.repo_url.startswith("https://"):
self.repo_url = os.path.expanduser(self.repo_url)

def _configure_proactive_http_auth(self, repo_url: str) -> None:
git_version = self._get_git_version_info()
if git_version is None:
warnings.warn(
"Could not determine the installed Git version, so proactive HTTPS authentication "
"is disabled. Git 2.46 or newer is required; challenge-based authentication remains active.",
RuntimeWarning,
stacklevel=2,
)
return
if git_version < self._MIN_PROACTIVE_AUTH_GIT_VERSION:
version = ".".join(map(str, git_version))
warnings.warn(
f"Installed Git {version} does not support proactive HTTPS authentication. "
"Git 2.46 or newer is required; challenge-based authentication remains active.",
RuntimeWarning,
stacklevel=2,
)
return

count = int(self.env.get("GIT_CONFIG_COUNT", os.environ.get("GIT_CONFIG_COUNT", "0")))
self.env["GIT_CONFIG_COUNT"] = str(count + 1)
self.env[f"GIT_CONFIG_KEY_{count}"] = f"http.{repo_url}.proactiveAuth"
self.env[f"GIT_CONFIG_VALUE_{count}"] = "basic"

def set_git_env(self, key: str | None = None) -> None:
self.env["GIT_SSH_COMMAND"] = self._build_ssh_command(key)

Expand Down
25 changes: 24 additions & 1 deletion providers/git/tests/unit/git/bundles/test_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ def teardown_class(cls) -> None:

# TODO: Potential performance issue, converted setup_class to a setup_connections function level fixture
@pytest.fixture(autouse=True)
def setup_connections(self, create_connection_without_db, request):
def setup_connections(self, create_connection_without_db, request, monkeypatch):
monkeypatch.setattr(GitHook, "_get_git_version_info", staticmethod(lambda: (2, 46, 0)))
# Skip setup for tests that need to create their own connections
if request.function.__name__ in ["test_view_url", "test_view_url_subdir"]:
return
Expand Down Expand Up @@ -123,6 +124,28 @@ def setup_connections(self, create_connection_without_db, request):
def test_supports_versioning(self):
assert GitDagBundle.supports_versioning is True

def test_fetch_bare_repo_uses_full_hook_environment(self):
bundle = GitDagBundle(name="test", git_conn_id=CONN_HTTPS, tracking_ref=GIT_DEFAULT_BRANCH)
hook_env = {
"GIT_SSH_COMMAND": "ssh -o StrictHostKeyChecking=yes",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": f"http.{AIRFLOW_HTTPS_URL}.proactiveAuth",
"GIT_CONFIG_VALUE_0": "basic",
}
bundle.hook.env = hook_env
bundle.bare_repo = mock.MagicMock()
environment_context = bundle.bare_repo.git.custom_environment.return_value

bundle._fetch_bare_repo()

bundle.bare_repo.git.custom_environment.assert_called_once_with(**hook_env)
environment_context.__enter__.assert_called_once_with()
environment_context.__exit__.assert_called_once()
bundle.bare_repo.remotes.origin.fetch.assert_called_once_with(
["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"]
)
bundle.bare_repo.close.assert_called_once_with()

def test_uses_dag_bundle_root_storage_path(self):
bundle = GitDagBundle(name="test", tracking_ref=GIT_DEFAULT_BRANCH)
base = get_bundle_storage_root_path()
Expand Down
60 changes: 59 additions & 1 deletion providers/git/tests/unit/git/hooks/test_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ def teardown_class(cls) -> None:

# TODO: Potential performance issue, converted setup_class to a setup_connections function level fixture
@pytest.fixture(autouse=True)
def setup_connections(self, create_connection_without_db):
def setup_connections(self, create_connection_without_db, monkeypatch):
monkeypatch.setattr(GitHook, "_get_git_version_info", staticmethod(lambda: (2, 46, 0)))
create_connection_without_db(
Connection(
conn_id=CONN_DEFAULT,
Expand Down Expand Up @@ -414,3 +415,60 @@ def test_passphrase_askpass_cleaned_up(self, create_connection_without_db):
assert os.path.exists(askpass_path)
# Both the askpass script and the temp key file should be cleaned up
assert not os.path.exists(askpass_path)

def test_https_auth_configures_proactive_auth(self):
hook = GitHook(git_conn_id=CONN_HTTPS)

assert hook.env == {
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": f"http.{AIRFLOW_HTTPS_URL}.proactiveAuth",
"GIT_CONFIG_VALUE_0": "basic",
}

def test_https_auth_appends_to_existing_git_config(self, monkeypatch):
monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
monkeypatch.setenv("GIT_CONFIG_KEY_0", "protocol.file.allow")
monkeypatch.setenv("GIT_CONFIG_VALUE_0", "always")

hook = GitHook(git_conn_id=CONN_HTTPS)

assert hook.env == {
"GIT_CONFIG_COUNT": "2",
"GIT_CONFIG_KEY_1": f"http.{AIRFLOW_HTTPS_URL}.proactiveAuth",
"GIT_CONFIG_VALUE_1": "basic",
}
assert os.environ["GIT_CONFIG_KEY_0"] == "protocol.file.allow"
assert os.environ["GIT_CONFIG_VALUE_0"] == "always"

def test_http_auth_does_not_configure_proactive_auth(self):
hook = GitHook(git_conn_id=CONN_HTTP)

assert not any(key.startswith("GIT_CONFIG_") for key in hook.env)

@pytest.mark.parametrize(
("git_version", "warning_match"),
[
((2, 45, 0), "Installed Git 2.45.0"),
(None, "Could not determine the installed Git version"),
],
)
def test_unsupported_git_warns_and_uses_challenge_auth(self, monkeypatch, git_version, warning_match):
monkeypatch.setattr(GitHook, "_get_git_version_info", staticmethod(lambda: git_version))

with pytest.warns(RuntimeWarning, match=warning_match):
hook = GitHook(git_conn_id=CONN_HTTPS)

assert hook.repo_url == f"https://user:{ACCESS_TOKEN}@github.com/apache/airflow.git"
assert not any(key.startswith("GIT_CONFIG_") for key in hook.env)

@pytest.mark.parametrize("conn_id", [CONN_HTTP_NO_AUTH, CONN_DEFAULT])
def test_non_https_token_connections_do_not_configure_proactive_auth(self, conn_id):
warning_context = (
pytest.warns(AirflowProviderDeprecationWarning, match="accept-new")
if conn_id == CONN_DEFAULT
else contextlib.nullcontext()
)
with warning_context:
hook = GitHook(git_conn_id=conn_id)

assert not any(key.startswith("GIT_CONFIG_") for key in hook.env)
Loading