Skip to content

Commit 66fb41f

Browse files
committed
OAuth: handle organization member events without a sync per event
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. Removed members are now revoked directly from the database using the event payload, without querying the API. Syncs triggered by member events are debounced per installation, so a bulk change triggers one sync instead of one per member. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjkPjH3EDEQmtywJvDVau
1 parent 8a26911 commit 66fb41f

2 files changed

Lines changed: 102 additions & 0 deletions

File tree

readthedocs/oauth/tasks.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
import structlog
77
from django.contrib.auth.models import User
8+
from django.core.cache import cache
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
@@ -757,6 +760,19 @@ def _handle_organization_event(self):
757760
# this is since we don't know to which repositories the members have access.
758761
# GH doesn't send a member event for this.
759762
if action in ("member_added", "member_removed"):
763+
if action == "member_removed":
764+
# We know which member was removed, so we can revoke their access
765+
# without querying the GH API. The sync below covers members whose
766+
# access changed in other ways, but it may be skipped completely
767+
# if the installation was synced recently.
768+
self._remove_member_repository_relations(installation)
769+
770+
# Bulk membership changes send one event per member, and each sync costs
771+
# at least one API request per repository in the installation, so we
772+
# debounce syncs to avoid exceeding the GitHub API rate limit.
773+
if self._installation_sync_recently_triggered(installation):
774+
log.info("Installation was synced recently, skipping sync.")
775+
return
760776
installation.service.sync()
761777
return
762778

@@ -781,6 +797,39 @@ def _handle_organization_event(self):
781797
# - member_invited: We don't do anything with invited members.
782798
return
783799

800+
def _remove_member_repository_relations(self, installation):
801+
"""
802+
Remove all repository relations from the member the event was triggered for.
803+
804+
A member removed from the organization lost access to all its repositories,
805+
so we can revoke their access without querying the GH API.
806+
"""
807+
member_id = self.data.get("membership", {}).get("user", {}).get("id")
808+
if not member_id:
809+
log.info("Organization event doesn't include the affected member.")
810+
return
811+
count, _ = RemoteRepositoryRelation.objects.filter(
812+
account__provider=GitHubAppProvider.id,
813+
account__uid=str(member_id),
814+
remote_repository__github_app_installation=installation,
815+
).delete()
816+
log.info(
817+
"Removed repository relations from member removed from the organization.",
818+
member_id=member_id,
819+
count=count,
820+
)
821+
822+
def _installation_sync_recently_triggered(self, installation) -> bool:
823+
"""
824+
Check if a sync was triggered recently for the installation, and mark it as synced.
825+
826+
``cache.add`` is atomic, so concurrent events trigger only one sync.
827+
The key isn't removed if the sync fails, otherwise a burst of events would
828+
keep triggering expensive syncs while we are rate limited.
829+
"""
830+
cache_key = f"githubapp-installation-sync:{installation.installation_id}"
831+
return not cache.add(cache_key, True, timeout=60 * 5)
832+
784833
def _handle_member_event(self):
785834
"""
786835
Handle the member event.

readthedocs/oauth/tests/test_githubapp_webhook.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from allauth.socialaccount.models import SocialAccount
88
from django.conf import settings
99
from django.contrib.auth.models import User
10+
from django.core.cache import cache
1011
from django.test import TestCase
1112
from django.urls import reverse
1213
from django_dynamic_fixture import get
@@ -32,6 +33,7 @@
3233
GitHubAppInstallation,
3334
RemoteOrganization,
3435
RemoteRepository,
36+
RemoteRepositoryRelation,
3537
)
3638
from readthedocs.oauth.services import GitHubAppService
3739
from readthedocs.projects.models import Project
@@ -81,6 +83,8 @@ def setUp(self):
8183
self.project.remote_repository = self.remote_repository
8284
self.project.save()
8385
self.url = reverse("github_app_webhook")
86+
# Syncs triggered by organization events are debounced with the cache.
87+
cache.clear()
8488

8589
def post_webhook(self, event, payload):
8690
headers = {
@@ -915,19 +919,66 @@ def test_organization_member_added(self, sync):
915919
assert r.status_code == 200
916920
sync.assert_called_once()
917921

922+
@mock.patch.object(GitHubAppService, "sync")
923+
def test_organization_member_events_are_debounced(self, sync):
924+
payload = {
925+
"installation": {
926+
"id": self.installation.installation_id,
927+
"target_id": self.installation.target_id,
928+
"target_type": self.installation.target_type,
929+
},
930+
"action": "member_added",
931+
}
932+
r = self.post_webhook("organization", payload)
933+
assert r.status_code == 200
934+
r = self.post_webhook("organization", payload)
935+
assert r.status_code == 200
936+
# Bulk membership changes send one event per member,
937+
# only the first one triggers a sync.
938+
sync.assert_called_once()
939+
918940
@mock.patch.object(GitHubAppService, "sync")
919941
def test_organization_member_removed(self, sync):
942+
member = get(User)
943+
member_account = get(
944+
SocialAccount,
945+
user=member,
946+
provider=GitHubAppProvider.id,
947+
uid="9999",
948+
)
949+
get(
950+
RemoteRepositoryRelation,
951+
remote_repository=self.remote_repository,
952+
user=member,
953+
account=member_account,
954+
)
955+
relation = get(
956+
RemoteRepositoryRelation,
957+
remote_repository=self.remote_repository,
958+
user=self.user,
959+
account=self.socialaccount,
960+
)
920961
payload = {
921962
"installation": {
922963
"id": self.installation.installation_id,
923964
"target_id": self.installation.target_id,
924965
"target_type": self.installation.target_type,
925966
},
926967
"action": "member_removed",
968+
"membership": {
969+
"user": {
970+
"login": "member",
971+
"id": 9999,
972+
},
973+
},
927974
}
928975
r = self.post_webhook("organization", payload)
929976
assert r.status_code == 200
930977
sync.assert_called_once()
978+
# The relations from the removed member are deleted without querying the API,
979+
# relations from other users are kept.
980+
assert not member.remote_repository_relations.exists()
981+
assert list(self.user.remote_repository_relations.all()) == [relation]
931982

932983
@mock.patch.object(GitHubAppService, "sync")
933984
def test_organization_renamed(self, sync):
@@ -1082,6 +1133,8 @@ def setUp(self):
10821133
self.project.remote_repository = self.remote_repository
10831134
self.project.save()
10841135
self.url = reverse("github_app_webhook")
1136+
# Syncs triggered by organization events are debounced with the cache.
1137+
cache.clear()
10851138

10861139
def post_webhook(self, event, payload):
10871140
headers = {

0 commit comments

Comments
 (0)