Skip to content

Commit 80962a8

Browse files
committed
OAuth: update the affected member directly on organization member events
Bulk membership changes send one organization event per member, and each event triggered a full sync of the installation, costing at least one API request per repository. Large installations exceed GitHub's per-installation rate limit this way. Since the event includes the affected member, we now update that member only. Removed members have their access revoked directly from the database, without querying the API. Added members with an account connected have their access checked against repositories linked to a project (one request each); the rest of their access is synced when they sign in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjkPjH3EDEQmtywJvDVau
1 parent 8a26911 commit 80962a8

4 files changed

Lines changed: 234 additions & 8 deletions

File tree

readthedocs/oauth/services/githubapp.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.conf import settings
88
from github import Github
99
from github import GithubException
10+
from github import RateLimitExceededException
1011
from github.Installation import Installation as GHInstallation
1112
from github.Organization import Organization as GHOrganization
1213
from github.Repository import Repository as GHRepository
@@ -394,6 +395,49 @@ def _get_social_accounts(self, ids):
394395
provider=self.allauth_provider.id,
395396
).select_related("user")
396397

398+
def update_member_access(self, account: SocialAccount, login: str):
399+
"""
400+
Update the relations of a user with the repositories linked to a project.
401+
402+
Instead of re-syncing all collaborators of all repositories,
403+
we check the permission of the user on each repository linked to a project
404+
(one API request per repository). Their access to the rest of the repositories
405+
is synced when they sign in or manually re-sync their repositories.
406+
407+
:param account: The social account connected to the user.
408+
:param login: The GitHub username of the user.
409+
"""
410+
remote_repositories = self.installation.repositories.filter(
411+
projects__isnull=False
412+
).distinct()
413+
for remote_repo in remote_repositories:
414+
try:
415+
# NOTE: we use the lazy option to avoid fetching the repository object,
416+
# since we only need the object to interact with the permissions API.
417+
gh_repo = self.installation_client.get_repo(int(remote_repo.remote_id), lazy=True)
418+
permission = gh_repo.get_collaborator_permission(login)
419+
except RateLimitExceededException:
420+
# All the remaining requests will fail as well.
421+
raise
422+
except GithubException:
423+
log.info(
424+
"Failed to fetch the repository permissions of the user",
425+
repository_id=remote_repo.remote_id,
426+
exc_info=True,
427+
)
428+
continue
429+
430+
if permission == "none":
431+
RemoteRepositoryRelation.objects.filter(
432+
remote_repository=remote_repo,
433+
account=account,
434+
).delete()
435+
continue
436+
437+
remote_repo_relation = remote_repo.get_remote_repository_relation(account.user, account)
438+
remote_repo_relation.admin = permission == "admin"
439+
remote_repo_relation.save()
440+
397441
def send_build_status(self, *, build, commit, status):
398442
"""
399443
Create a commit status on GitHub for the given build.

readthedocs/oauth/tasks.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
from functools import cached_property
55

66
import structlog
7+
from allauth.socialaccount.models import SocialAccount
78
from django.contrib.auth.models import User
89
from django.db.models.functions import ExtractIsoWeekDay
910
from django.urls import reverse
1011
from django.utils import timezone
1112

13+
from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
1214
from readthedocs.api.v2.views.integrations import ExternalVersionData
1315
from readthedocs.builds.constants import EXTERNAL
1416
from readthedocs.builds.utils import memcache_lock
@@ -25,6 +27,7 @@
2527
from readthedocs.oauth.constants import GITHUB_APP
2628
from readthedocs.oauth.models import GitHubAppInstallation
2729
from readthedocs.oauth.models import RemoteRepository
30+
from readthedocs.oauth.models import RemoteRepositoryRelation
2831
from readthedocs.oauth.notifications import MESSAGE_OAUTH_SYNCING_REMOTE_REPOSITORIES
2932
from readthedocs.oauth.notifications import MESSAGE_OAUTH_UNSUPPORTED_GIT_PROVIDER
3033
from readthedocs.oauth.notifications import MESSAGE_OAUTH_WEBHOOK_INTEGRATION_MISMATCH
@@ -753,11 +756,12 @@ def _handle_organization_event(self):
753756
if created:
754757
return
755758

756-
# We need to do a full sync of the repositories if members were added or removed,
757-
# this is since we don't know to which repositories the members have access.
758-
# GH doesn't send a member event for this.
759-
if action in ("member_added", "member_removed"):
760-
installation.service.sync()
759+
if action == "member_added":
760+
self._add_member_repository_relations(installation)
761+
return
762+
763+
if action == "member_removed":
764+
self._remove_member_repository_relations(installation)
761765
return
762766

763767
# NOTE: installation_target should handle this instead?
@@ -781,6 +785,49 @@ def _handle_organization_event(self):
781785
# - member_invited: We don't do anything with invited members.
782786
return
783787

788+
def _add_member_repository_relations(self, installation):
789+
"""
790+
Grant the member the event was triggered for access to relevant repositories.
791+
792+
If the member has an account connected, we check their access to each
793+
repository linked to a project. Their access to the rest of the repositories
794+
is synced when they sign in or manually re-sync their repositories.
795+
"""
796+
member = self.data.get("membership", {}).get("user", {})
797+
member_id = member.get("id")
798+
if not member_id:
799+
log.info("Organization event doesn't include the affected member.")
800+
return
801+
account = SocialAccount.objects.filter(
802+
provider=GitHubAppProvider.id,
803+
uid=str(member_id),
804+
).first()
805+
if not account:
806+
return
807+
installation.service.update_member_access(account, member["login"])
808+
809+
def _remove_member_repository_relations(self, installation):
810+
"""
811+
Remove all repository relations from the member the event was triggered for.
812+
813+
A member removed from the organization lost access to all its repositories,
814+
so we can revoke their access without querying the GH API.
815+
"""
816+
member_id = self.data.get("membership", {}).get("user", {}).get("id")
817+
if not member_id:
818+
log.info("Organization event doesn't include the affected member.")
819+
return
820+
count, _ = RemoteRepositoryRelation.objects.filter(
821+
account__provider=GitHubAppProvider.id,
822+
account__uid=str(member_id),
823+
remote_repository__github_app_installation=installation,
824+
).delete()
825+
log.info(
826+
"Removed repository relations from member removed from the organization.",
827+
member_id=member_id,
828+
count=count,
829+
)
830+
784831
def _handle_member_event(self):
785832
"""
786833
Handle the member event.

readthedocs/oauth/tests/test_githubapp_webhook.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
GitHubAppInstallation,
3333
RemoteOrganization,
3434
RemoteRepository,
35+
RemoteRepositoryRelation,
3536
)
3637
from readthedocs.oauth.services import GitHubAppService
3738
from readthedocs.projects.models import Project
@@ -901,33 +902,101 @@ def test_repository_created(self):
901902
r = self.post_webhook("repository", payload)
902903
assert r.status_code == 200
903904

905+
@mock.patch.object(GitHubAppService, "update_member_access")
904906
@mock.patch.object(GitHubAppService, "sync")
905-
def test_organization_member_added(self, sync):
907+
def test_organization_member_added(self, sync, update_member_access):
908+
member = get(User)
909+
member_account = get(
910+
SocialAccount,
911+
user=member,
912+
provider=GitHubAppProvider.id,
913+
uid="9999",
914+
)
906915
payload = {
907916
"installation": {
908917
"id": self.installation.installation_id,
909918
"target_id": self.installation.target_id,
910919
"target_type": self.installation.target_type,
911920
},
912921
"action": "member_added",
922+
"membership": {
923+
"user": {
924+
"login": "member",
925+
"id": 9999,
926+
},
927+
},
913928
}
914929
r = self.post_webhook("organization", payload)
915930
assert r.status_code == 200
916-
sync.assert_called_once()
931+
update_member_access.assert_called_once_with(member_account, "member")
932+
sync.assert_not_called()
933+
934+
@mock.patch.object(GitHubAppService, "update_member_access")
935+
@mock.patch.object(GitHubAppService, "sync")
936+
def test_organization_member_added_without_account(self, sync, update_member_access):
937+
payload = {
938+
"installation": {
939+
"id": self.installation.installation_id,
940+
"target_id": self.installation.target_id,
941+
"target_type": self.installation.target_type,
942+
},
943+
"action": "member_added",
944+
"membership": {
945+
"user": {
946+
"login": "member",
947+
"id": 9999,
948+
},
949+
},
950+
}
951+
r = self.post_webhook("organization", payload)
952+
assert r.status_code == 200
953+
# The member doesn't have an account connected,
954+
# their access is synced when they sign in.
955+
update_member_access.assert_not_called()
956+
sync.assert_not_called()
917957

918958
@mock.patch.object(GitHubAppService, "sync")
919959
def test_organization_member_removed(self, sync):
960+
member = get(User)
961+
member_account = get(
962+
SocialAccount,
963+
user=member,
964+
provider=GitHubAppProvider.id,
965+
uid="9999",
966+
)
967+
get(
968+
RemoteRepositoryRelation,
969+
remote_repository=self.remote_repository,
970+
user=member,
971+
account=member_account,
972+
)
973+
relation = get(
974+
RemoteRepositoryRelation,
975+
remote_repository=self.remote_repository,
976+
user=self.user,
977+
account=self.socialaccount,
978+
)
920979
payload = {
921980
"installation": {
922981
"id": self.installation.installation_id,
923982
"target_id": self.installation.target_id,
924983
"target_type": self.installation.target_type,
925984
},
926985
"action": "member_removed",
986+
"membership": {
987+
"user": {
988+
"login": "member",
989+
"id": 9999,
990+
},
991+
},
927992
}
928993
r = self.post_webhook("organization", payload)
929994
assert r.status_code == 200
930-
sync.assert_called_once()
995+
sync.assert_not_called()
996+
# The relations from the removed member are deleted without querying the API,
997+
# relations from other users are kept.
998+
assert not member.remote_repository_relations.exists()
999+
assert list(self.user.remote_repository_relations.all()) == [relation]
9311000

9321001
@mock.patch.object(GitHubAppService, "sync")
9331002
def test_organization_renamed(self, sync):

readthedocs/rtd_tests/tests/test_oauth.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,72 @@ def test_update_invalid_repository(self, request):
596596
id=self.remote_repository.id
597597
).exists()
598598

599+
@requests_mock.Mocker(kw="request")
600+
def test_update_member_access(self, request):
601+
member = get(User)
602+
member_account = get(
603+
SocialAccount,
604+
uid="9999",
605+
user=member,
606+
provider=GitHubAppProvider.id,
607+
)
608+
# ``self.remote_repository`` is linked to a project, ``repo2`` is not.
609+
repo2 = get(
610+
RemoteRepository,
611+
remote_id="7777",
612+
name="repo2",
613+
full_name="user/repo2",
614+
vcs_provider=GITHUB_APP,
615+
github_app_installation=self.installation,
616+
)
617+
request.post(
618+
f"{self.api_url}/app/installations/1111/access_tokens",
619+
json=self._get_access_token_json(),
620+
)
621+
# Only permissions on repositories linked to a project are checked,
622+
# requesting the permissions on user/repo2 would fail the test (not mocked).
623+
request.get(
624+
f"{self.api_url}/repositories/{self.remote_repository.remote_id}/collaborators/member/permission",
625+
json={"permission": "admin"},
626+
)
627+
628+
service = self.installation.service
629+
service.update_member_access(member_account, "member")
630+
631+
relation = member.remote_repository_relations.get()
632+
assert relation.remote_repository == self.remote_repository
633+
assert relation.account == member_account
634+
assert relation.admin is True
635+
636+
@requests_mock.Mocker(kw="request")
637+
def test_update_member_access_without_access(self, request):
638+
member = get(User)
639+
member_account = get(
640+
SocialAccount,
641+
uid="9999",
642+
user=member,
643+
provider=GitHubAppProvider.id,
644+
)
645+
get(
646+
RemoteRepositoryRelation,
647+
remote_repository=self.remote_repository,
648+
user=member,
649+
account=member_account,
650+
)
651+
request.post(
652+
f"{self.api_url}/app/installations/1111/access_tokens",
653+
json=self._get_access_token_json(),
654+
)
655+
request.get(
656+
f"{self.api_url}/repositories/{self.remote_repository.remote_id}/collaborators/member/permission",
657+
json={"permission": "none"},
658+
)
659+
660+
service = self.installation.service
661+
service.update_member_access(member_account, "member")
662+
663+
assert not member.remote_repository_relations.exists()
664+
599665
@requests_mock.Mocker(kw="request")
600666
def test_sync(self, request):
601667
assert self.installation.repositories.count() == 1

0 commit comments

Comments
 (0)