Skip to content

Commit abf58ae

Browse files
authored
Build: support cloning private repos with token (#12115)
First I wanted to pass the env var just in the clone step, but we don't allow passing additional env vars once the environment is created, so it's available in the whole "clone" environment. The access token we create is read-only, and should be scoped to just one project as well (waiting on PyGithub/PyGithub#3287). Once the clone is done, the token is stored in the .git/config file, so that token isn't always kept secret from the rest of the build like ssh keys, but since the token is read-only and scoped to the current project, and temporary (1 hour). It should be fine. Additionally, the token is only created for private repos, meaning that only people with explicit access to the repo may be able to extract the token, but again, since they already have access to the repo, there is no additional permissions the token is granting to the user (will document this in #12114).
1 parent fabea92 commit abf58ae

File tree

10 files changed

+387
-24
lines changed

10 files changed

+387
-24
lines changed

readthedocs/api/v2/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ class Meta(ProjectSerializer.Meta):
9393
"environment_variables",
9494
"max_concurrent_builds",
9595
"readthedocs_yaml_path",
96+
"clone_token",
9697
)
9798

9899

readthedocs/doc_builder/director.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,7 @@ def get_vcs_env_vars(self):
679679
env = self.get_rtd_env_vars()
680680
# Don't prompt for username, this requires Git 2.3+
681681
env["GIT_TERMINAL_PROMPT"] = "0"
682+
env["READTHEDOCS_GIT_CLONE_TOKEN"] = self.data.project.clone_token
682683
return env
683684

684685
def get_rtd_env_vars(self):

readthedocs/doc_builder/environments.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ def _escape_command(self, cmd):
391391
not_escape_variables = (
392392
"READTHEDOCS_OUTPUT",
393393
"READTHEDOCS_VIRTUALENV_PATH",
394+
"READTHEDOCS_GIT_CLONE_TOKEN",
394395
"CONDA_ENVS_PATH",
395396
"CONDA_DEFAULT_ENV",
396397
)

readthedocs/oauth/services/base.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Service:
3838
default_user_avatar_url = settings.OAUTH_AVATAR_USER_DEFAULT_URL
3939
default_org_avatar_url = settings.OAUTH_AVATAR_ORG_DEFAULT_URL
4040
supports_build_status = False
41+
supports_clone_token = False
4142

4243
@classmethod
4344
def for_project(cls, project):
@@ -328,7 +329,3 @@ def sync_repositories(self):
328329

329330
def sync_organizations(self):
330331
raise NotImplementedError
331-
332-
def get_clone_token(self, project):
333-
"""User services make use of SSH keys only for cloning."""
334-
return None

readthedocs/oauth/services/githubapp.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class GitHubAppService(Service):
3434
vcs_provider_slug = GITHUB_APP
3535
allauth_provider = GitHubAppProvider
3636
supports_build_status = True
37+
supports_clone_token = True
3738

3839
def __init__(self, installation: GitHubAppInstallation):
3940
self.installation = installation
@@ -462,17 +463,29 @@ def get_clone_token(self, project):
462463
"""
463464
Return a token for HTTP-based Git access to the repository.
464465
466+
The token is scoped to have read-only access to the content of the repository attached to the project.
467+
The token expires after one hour (this is given by GitHub and can't be changed).
468+
465469
See:
466470
- https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
467471
- https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app
468472
"""
469-
# NOTE: we can pass the repository_ids to get a token with access to specific repositories.
470-
# We should upstream this feature to PyGithub.
471-
# We can also pass a specific permissions object to get a token with specific permissions
472-
# if we want to scope this token even more.
473473
try:
474-
access_token = self.gh_app_client.get_access_token(self.installation.installation_id)
475-
return f"x-access-token:{access_token.token}"
474+
# TODO: Use self.gh_app_client.get_access_token instead,
475+
# once https://github.com/PyGithub/PyGithub/pull/3287 is merged.
476+
_, response = self.gh_app_client.requester.requestJsonAndCheck(
477+
"POST",
478+
f"/app/installations/{self.installation.installation_id}/access_tokens",
479+
headers=self.gh_app_client._get_headers(),
480+
input={
481+
"repository_ids": [int(project.remote_repository.remote_id)],
482+
"permissions": {
483+
"contents": "read",
484+
},
485+
},
486+
)
487+
token = response["token"]
488+
return f"x-access-token:{token}"
476489
except GithubException:
477490
log.info(
478491
"Failed to get clone token for project",

readthedocs/projects/models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,7 @@ def vcs_repo(self, environment, version):
968968
self,
969969
version=version,
970970
environment=environment,
971+
use_token=bool(self.clone_token),
971972
)
972973
return repo
973974

@@ -1398,6 +1399,29 @@ def get_subproject_candidates(self, user):
13981399
def organization(self):
13991400
return self.organizations.first()
14001401

1402+
@property
1403+
def clone_token(self) -> str | None:
1404+
"""
1405+
Return a HTTP-based Git access token to the repository.
1406+
1407+
.. note::
1408+
1409+
- A token is only returned for projects linked to a private repository.
1410+
- Only repositories granted access by a GitHub app installation will return a token.
1411+
"""
1412+
service_class = self.get_git_service_class()
1413+
if not service_class or not self.remote_repository.private:
1414+
return None
1415+
1416+
if not service_class.supports_clone_token:
1417+
return None
1418+
1419+
for service in service_class.for_project(self):
1420+
token = service.get_clone_token(self)
1421+
if token:
1422+
return token
1423+
return None
1424+
14011425

14021426
class APIProject(Project):
14031427
"""
@@ -1414,12 +1438,17 @@ class APIProject(Project):
14141438
"""
14151439

14161440
features = []
1441+
# This is a property in the original model, in order to
1442+
# be able to assign it a value in the constructor, we need to re-declare it
1443+
# as an attribute here.
1444+
clone_token = None
14171445

14181446
class Meta:
14191447
proxy = True
14201448

14211449
def __init__(self, *args, **kwargs):
14221450
self.features = kwargs.pop("features", [])
1451+
self.clone_token = kwargs.pop("clone_token", None)
14231452
environment_variables = kwargs.pop("environment_variables", {})
14241453
ad_free = not kwargs.pop("show_advertising", True)
14251454
# These fields only exist on the API return, not on the model, so we'll

readthedocs/projects/tasks/builds.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def execute(self):
205205
version=self.data.version,
206206
environment={
207207
"GIT_TERMINAL_PROMPT": "0",
208+
"READTHEDOCS_GIT_CLONE_TOKEN": self.data.project.clone_token,
208209
},
209210
# Pass the api_client so that all environments have it.
210211
# This is needed for ``readthedocs-corporate``.

0 commit comments

Comments
 (0)