Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate GitHub OAuth App to GitHub App #11942

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
Empty file.
10 changes: 10 additions & 0 deletions readthedocs/allauth/providers/githubapp/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from allauth.socialaccount.providers.github.provider import GitHubProvider
from readthedocs.allauth.providers.githubapp.views import GitHubAppOAuth2Adapter


class GitHubAppProvider(GitHubProvider):
id = "githubapp"
name = "GitHub App"
oauth2_adapter_class = GitHubAppOAuth2Adapter

provider_classes = [GitHubAppProvider]
6 changes: 6 additions & 0 deletions readthedocs/allauth/providers/githubapp/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns

from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider


urlpatterns = default_urlpatterns(GitHubAppProvider)
13 changes: 13 additions & 0 deletions readthedocs/allauth/providers/githubapp/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter

from allauth.socialaccount.providers.oauth2.views import (
OAuth2CallbackView,
OAuth2LoginView,
)

class GitHubAppOAuth2Adapter(GitHubOAuth2Adapter):
provider_id = 'githubapp'


oauth2_login = OAuth2LoginView.adapter_view(GitHubAppOAuth2Adapter)
oauth2_callback = OAuth2CallbackView.adapter_view(GitHubAppOAuth2Adapter)
15 changes: 15 additions & 0 deletions readthedocs/core/adapters.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"""Allauth overrides."""

from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.providers.github.provider import GitHubProvider

import structlog
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.utils.encoding import force_str

from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
from readthedocs.core.utils import send_email_from_object
from readthedocs.invitations.models import Invitation

Expand Down Expand Up @@ -64,4 +69,14 @@ def pre_social_login(self, request, sociallogin):
"""
sociallogin.email_addresses = [
email for email in sociallogin.email_addresses if email.primary

]

provider = sociallogin.account.get_provider()
if provider.id == GitHubAppProvider.id and not sociallogin.is_existing:
stsewd marked this conversation as resolved.
Show resolved Hide resolved
social_account = SocialAccount.objects.filter(
provider=GitHubProvider.id,
uid=sociallogin.account.uid,
).first()
if social_account:
sociallogin.connect(request, social_account.user)
52 changes: 51 additions & 1 deletion readthedocs/oauth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,47 @@
from .querysets import RemoteOrganizationQuerySet, RemoteRepositoryQuerySet


class RemoteOrganization(TimeStampedModel):
class GitHubAccountType(models.TextChoices):
USER = "User", _("User")
ORGANIZATION = "Organization", _("Organization")


class GitHubAppInstallation(TimeStampedModel):
# Should we just use big int?
installation_id = models.CharField(
help_text=_("The application installation ID"),
max_length=255,
unique=True,
db_index=True,
)
target_id = models.CharField(
help_text=_("A GitHub account ID, it can be from a user or an organization"),
max_length=255,
)
target_type = models.CharField(
help_text=_(
"Account type that the target_id belongs to (user or organization)"
),
choices=GitHubAccountType.choices,
max_length=255,
)
extra_data = models.JSONField(
help_text=_(
"Extra data returned by the webhook when the installation is created"
),
default=dict,
)

class Meta(TimeStampedModel.Meta):
constraints = [
models.UniqueConstraint(
fields=["target_id", "target_type"],
name="unique_target_id_target_type",
)
]


class RemoteOrganization(TimeStampedModel):
"""
Organization from remote service.

Expand Down Expand Up @@ -174,6 +213,17 @@ class RemoteRepository(TimeStampedModel):
_("VCS provider"), choices=VCS_PROVIDER_CHOICES, max_length=32
)

github_app_installation = models.ForeignKey(
GitHubAppInstallation,
verbose_name=_("GitHub App Installation"),
related_name="repositories",
null=True,
blank=True,
# Delete the repository if the installation is deleted?
# or keep the repository and just remove the installation?
on_delete=models.SET_NULL,
)

objects = RemoteRepositoryQuerySet.as_manager()

class Meta:
Expand Down
185 changes: 185 additions & 0 deletions readthedocs/oauth/services/githubapp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import structlog
from allauth.socialaccount.models import SocialAccount
from django.conf import settings
from github import Auth, GithubIntegration
from github.Installation import Installation as GHInstallation
from github.NamedUser import NamedUser as GHNamedUser
from github.Repository import Repository as GHRepository

from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
from readthedocs.oauth.constants import GITHUB
from readthedocs.oauth.models import (
GitHubAccountType,
GitHubAppInstallation,
RemoteOrganization,
RemoteRepository,
RemoteRepositoryRelation,
)

log = structlog.get_logger(__name__)

class GitHubAppService:
vcs_provider_slug = GITHUB
def __init__(self, installation: GitHubAppInstallation):
self.installation = installation
self._client = None

def _get_auth(self):
app_auth = Auth.AppAuth(
app_id=settings.GITHUB_APP_CLIENT_ID,
private_key=settings.GITHUB_APP_PRIVATE_KEY,
# 10 minutes is the maximum allowed by GitHub.
# PyGithub will handle the token expiration and renew it automatically.
jwt_expiry=60 * 10,
)
return app_auth

@property
def client(self):
"""Return a client authenticated as the GitHub App to interact with most of the GH API"""
if self._client is None:
self._client = self.integration_client.get_github_for_installation(self.installation.installation_id)
return self._client

@property
def integration_client(self):
"""Return a client authenticated as the GitHub App to interact with the installation API"""
if self._integration_client is None:
self._integration_client = GithubIntegration(auth=self._get_auth())
return self._integration_client

@property
def app_installation(self) -> GHInstallation:
return self.integration_client.get_app_installation(self.installation.installation_id)

def sync_repositories(self):
if self.installation.target_type != GitHubAccountType.USER:
return
return self._sync_installation_repositories()

def _sync_installation_repositories(self):
remote_repositories = []
for repo in self.app_installation.get_repos():
remote_repo = self.create_or_update_repository(repo)
if remote_repo:
remote_repositories.append(remote_repo)

# Remove repositories that are no longer in the list.
RemoteRepository.objects.filter(
github_app_installation=self.installation,
vcs_provider=self.vcs_provider_slug,
).exclude(
pk__in=[repo.pk for repo in remote_repositories],
).delete()

def add_repositories(self, repository_ids: list[int]):
for repository_id in repository_ids:
repo = self.client.get_repo(repository_id)
self.create_or_update_repository(repo)

def remove_repositories(self, repository_ids: list[int]):
RemoteRepository.objects.filter(
github_app_installation=self.installation,
vcs_provider=self.vcs_provider_slug,
remote_id__in=repository_ids,
).delete()

def create_or_update_repository(self, repo: GHRepository) -> RemoteRepository | None
if not settings.ALLOW_PRIVATE_REPOS and repo.private:
return

remote_repo, _ = RemoteRepository.objects.get_or_create(
remote_id=str(repo.id),
vcs_provider=self.vcs_provider_slug,
)

remote_repo.name = repo.name
remote_repo.full_name = repo.full_name
remote_repo.description = repo.description
remote_repo.avatar_url = repo.owner.avatar_url
remote_repo.ssh_url = repo.ssh_url
remote_repo.html_url = repo.html_url
remote_repo.private = repo.private
remote_repo.default_branch = repo.default_branch

# TODO: Do we need the SSH URL for private repositories now that we can clone using a token?
remote_repo.clone_url = repo.ssh_url if repo.private else repo.clone_url

# NOTE: Only one installation of our APP should give access to a repository.
# This should only happen if our data is out of sync.
if remote_repo.github_app_installation and remote_repo.github_app_installation != self.installation:
log.info(
"Repository linked to another installation",
repository_id=remote_repo.remote_id,
old_installation_id=remote_repo.github_app_installation.installation_id,
new_installation_id=self.installation.installation_id,
)
remote_repo.github_app_installation = self.installation

remote_repo.organization = None
if repo.owner.type == GitHubAccountType.ORGANIZATION:
remote_repo.organization = self.create_or_update_organization(repo.owner)

self._resync_collaborators(repo, remote_repo)
# What about memmbers of the organization? do we care?
# I think all of our permissions are based on the collaborators of the repository,
# not the members of the organization.
remote_repo.save()
return remote_repo

def create_or_update_organization(self, org: GHNamedUser) -> RemoteOrganization:
remote_org, _ = RemoteOrganization.objects.get_or_create(
remote_id=str(org.id),
vcs_provider=self.vcs_provider_slug,
)
remote_org.slug = org.login
remote_org.name = org.name
# NOTE: do we need the email of the organization?
remote_org.email = org.email
remote_org.avatar_url = org.avatar_url
remote_org.url = org.html_url
remote_org.save()
return remote_org

def _resync_collaborators(self, repo: GHRepository, remote_repo: RemoteRepository):
"""
Sync collaborators of a repository with the database.

This method will remove collaborators that are no longer in the list.
"""
collaborators = {
collaborator.id: collaborator
# Return all collaborators or just the ones with admin permission?
for collaborator in repo.get_collaborators()
}
remote_repo_relations_ids = []
for account in self._get_social_accounts(collaborators.keys()):
remote_repo_relation, _ = RemoteRepositoryRelation.objects.get_or_create(
remote_repository=remote_repo,
account=account,
)
remote_repo_relation.user = account.user
remote_repo_relation.admin = collaborators[account.uid].permissions.admin
remote_repo_relation.save()
remote_repo_relations_ids.append(remote_repo_relation.pk)

# Remove collaborators that are no longer in the list.
RemoteRepositoryRelation.objects.filter(
remote_repository=remote_repo,
).exclude(
pk__in=remote_repo_relations_ids,
).delete()

def _get_social_account(self, id):
return self._get_social_accounts([id]).first()

def _get_social_accounts(self, ids):
return SocialAccount.objects.filter(
uid__in=ids,
provider=GitHubAppProvider.id,
).select_related("user")

def sync_organizations(self):
expected_organization = self.app_installation.account
if self.installation.target_type != GitHubAccountType.ORGANIZATION:
return
7 changes: 7 additions & 0 deletions readthedocs/oauth/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls import path

from readthedocs.oauth.views import GitHubAppWebhookView

urlpatterns = [
path("githubapp/", GitHubAppWebhookView.as_view(), name="github_app_webhook"),
]
Loading