Merge ~cjwatson/launchpad:git-ignore-auth-on-notify into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: b03e7fbb5bcf867669ad6fe5a684fdbe24e136df
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:git-ignore-auth-on-notify
Merge into: launchpad:master
Diff against target: 301 lines (+32/-216)
2 files modified
lib/lp/code/xmlrpc/git.py (+22/-21)
lib/lp/code/xmlrpc/tests/test_git.py (+10/-195)
Reviewer Review Type Date Requested Status
Guruprasad Approve
Review via email: mp+436545@code.launchpad.net

Commit message

Ignore authentication for git push notifications

Description of the change

Back when Ioana was implementing sending pack statistics for git push notifications (which are emitted by turnip at the end of each push), one of my review comments was that, since this was directly updating properties of the repository, the call ought to be authenticated. I remember being slightly ambivalent about that even at the time, but we went along with it since it seemed a relatively harmless piece of caution.

However, recently I've been experimenting with getting snapcraft to use personal access tokens, and one thing I noticed was that authenticated push notifications make it difficult to use appropriate expiry times. `snapcraft remote-build` creates a repository, generates an access token for it, and then pushes to it; the token relieves the user of the need to set up an SSH key before this system can work. The time between generating the token and starting the push is very short, and the token is only used once, so it seems as though a short expiry time (perhaps a minute) should be more than enough. However, because `notify` is called at the end of the push and also checks authorization, we have to choose an expiry time long enough to push an entire potentially-large repository over a connection of unknown speed.

We already have to trust turnip to handle part of the git authorization process, so an authorization check that effectively just ensures that turnip hasn't processed a push without appropriate authorization doesn't add any interesting security. Being able to update pack statistics on a repository would be a very low-value attack in any case. On the other hand, being able to use short-expiry tokens effectively feels like a much clearer security advantage.

I'm therefore reversing my previous view: it seems acceptably safe to skip authentication for `notify`, given the other safeguards around that and the benefits for use of personal access tokens.

To post a comment you must log in.
Revision history for this message
Guruprasad (lgp171188) wrote :

LGTM 👍

review: Approve
Revision history for this message
Otto Co-Pilot (otto-copilot) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/xmlrpc/git.py b/lib/lp/code/xmlrpc/git.py
2index 9793646..3b97a85 100644
3--- a/lib/lp/code/xmlrpc/git.py
4+++ b/lib/lp/code/xmlrpc/git.py
5@@ -589,11 +589,11 @@ class GitAPI(LaunchpadXMLRPCView):
6 del result
7
8 @return_fault
9- def _notify(self, requester, translated_path, statistics, auth_params):
10+ def _notify(self, translated_path, statistics, auth_params):
11 logger = self._getLogger()
12- if requester == LAUNCHPAD_ANONYMOUS:
13- requester = None
14- repository = getUtility(IGitLookup).getByHostingPath(translated_path)
15+ repository = removeSecurityProxy(
16+ getUtility(IGitLookup).getByHostingPath(translated_path)
17+ )
18 if repository is None:
19 fault = faults.NotFound(
20 "No repository found for '%s'." % translated_path
21@@ -602,22 +602,30 @@ class GitAPI(LaunchpadXMLRPCView):
22 return fault
23 if repository is None:
24 raise faults.GitRepositoryNotFound(translated_path)
25- if auth_params is not None:
26- verified = self._verifyAuthParams(
27- requester, repository, auth_params
28+ if statistics:
29+ removeSecurityProxy(repository).setRepackData(
30+ statistics.get("loose_object_count"),
31+ statistics.get("pack_count"),
32 )
33- writable = self._isWritable(requester, repository, verified)
34- if writable and statistics:
35- removeSecurityProxy(repository).setRepackData(
36- statistics.get("loose_object_count"),
37- statistics.get("pack_count"),
38- )
39 getUtility(IGitRefScanJobSource).create(
40 removeSecurityProxy(repository)
41 )
42
43 def notify(self, translated_path, statistics, auth_params):
44 """See `IGitAPI`."""
45+ # This receives authentication parameters for historical reasons,
46+ # but ignores them. We already checked authorization at the start
47+ # of the operation whose completion we're now being notified about,
48+ # so we don't do so again here, as it can have weird effects: for
49+ # example, it should be possible to have a short-duration personal
50+ # access token that expires between the start and the end of a long
51+ # push operation. We have to trust turnip anyway, and the worst
52+ # thing that any of this can do is spuriously update statistics.
53+ #
54+ # If we feel the need to authorize notify calls in future, then it
55+ # should be done by checking whether a previous operation was
56+ # authorized, e.g. by generating a single-use token earlier. At the
57+ # moment this seems like overkill, though.
58 logger = self._getLogger()
59 logger.info(
60 "Request received: notify('%s', '%d', '%d')",
61@@ -626,14 +634,7 @@ class GitAPI(LaunchpadXMLRPCView):
62 statistics.get("pack_count"),
63 )
64
65- requester_id = _get_requester_id(auth_params)
66- result = run_with_login(
67- requester_id,
68- self._notify,
69- translated_path,
70- statistics,
71- auth_params,
72- )
73+ result = self._notify(translated_path, statistics, auth_params)
74 try:
75 if isinstance(result, xmlrpc.client.Fault):
76 logger.error("notify failed: %r", result)
77diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py
78index 35f25da..1f69b8e 100644
79--- a/lib/lp/code/xmlrpc/tests/test_git.py
80+++ b/lib/lp/code/xmlrpc/tests/test_git.py
81@@ -3158,210 +3158,25 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
82 [job] = list(job_source.iterReady())
83 self.assertEqual(repository, job.repository)
84
85- def assertSetsRepackData(self, repo, auth_params):
86+ def test_notify_set_repack_data(self):
87+ # The notify call sets the repack indicators (loose_object_count,
88+ # pack_count, date_last_scanned) when received from turnip.
89+ requester_owner = self.factory.makePerson()
90+ repository = self.factory.makeGitRepository(owner=requester_owner)
91 start_time = datetime.now(timezone.utc)
92 self.assertIsNone(
93 self.assertDoesNotFault(
94 None,
95 "notify",
96- repo.getInternalPath(),
97+ repository.getInternalPath(),
98 {"loose_object_count": 5, "pack_count": 2},
99- auth_params,
100+ {"uid": requester_owner.id},
101 )
102 )
103 end_time = datetime.now(timezone.utc)
104- naked_repo = removeSecurityProxy(repo)
105- self.assertEqual(5, naked_repo.loose_object_count)
106- self.assertEqual(2, naked_repo.pack_count)
107- self.assertBetween(start_time, naked_repo.date_last_scanned, end_time)
108-
109- def test_notify_set_repack_data(self):
110- # The notify call sets the repack
111- # indicators (loose_object_count, pack_count, date_last_scanned)
112- # when received from Turnip for a user
113- # that has their ordinary privileges on the corresponding repository
114- requester_owner = self.factory.makePerson()
115- repository = self.factory.makeGitRepository(owner=requester_owner)
116-
117- self.assertSetsRepackData(repository, {"uid": requester_owner.id})
118-
119- def test_notify_set_repack_data_user_macaroon(self):
120- self.pushConfig("codehosting", git_macaroon_secret_key="some-secret")
121- requester = self.factory.makePerson()
122- repository = self.factory.makeGitRepository(owner=requester)
123- issuer = getUtility(IMacaroonIssuer, "git-repository")
124- with person_logged_in(requester):
125- macaroon = removeSecurityProxy(issuer).issueMacaroon(
126- repository, user=requester
127- )
128- auth_params = _make_auth_params(
129- requester, macaroon_raw=macaroon.serialize()
130- )
131- self.assertSetsRepackData(repository, auth_params)
132-
133- def test_notify_set_repack_data_user_mismatch(self):
134- # notify refuses macaroons in the case where the
135- # user doesn't match what the issuer claims was verified. (We use a
136- # fake issuer for this, since this is a stopgap check to defend
137- # against issuer bugs)
138-
139- issuer = FakeMacaroonIssuer()
140- # Claim to be the code-import-job issuer. This is a bit weird, but
141- # it gets us past the difficulty that only certain named issuers are
142- # allowed to confer write permissions.
143- issuer.identifier = "code-import-job"
144- self.useFixture(
145- ZopeUtilityFixture(issuer, IMacaroonIssuer, name="code-import-job")
146- )
147- requesters = [self.factory.makePerson() for _ in range(2)]
148- owner = self.factory.makeTeam(members=requesters)
149- repository = self.factory.makeGitRepository(owner=owner)
150- macaroon = issuer.issueMacaroon(repository)
151-
152- for verified_user, authorized, unauthorized in (
153- (
154- requesters[0],
155- [requesters[0]],
156- [LAUNCHPAD_SERVICES, requesters[1], None],
157- ),
158- ([None, NO_USER], [], [LAUNCHPAD_SERVICES] + requesters + [None]),
159- ):
160- issuer._verified_user = verified_user
161- for requester in authorized:
162- login(ANONYMOUS)
163- auth_params = _make_auth_params(
164- requester, macaroon_raw=macaroon.serialize()
165- )
166- self.assertSetsRepackData(repository, auth_params)
167-
168- for requester in unauthorized:
169- login(ANONYMOUS)
170- auth_params = _make_auth_params(
171- requester, macaroon_raw=macaroon.serialize()
172- )
173- self.assertFault(
174- faults.Unauthorized,
175- None,
176- "notify",
177- repository.getInternalPath(),
178- {"loose_object_count": 5, "pack_count": 2},
179- auth_params,
180- )
181-
182- def test_notify_set_repack_data_user_access_token(self):
183- requester = self.factory.makePerson()
184- repository = self.factory.makeGitRepository(owner=requester)
185- _, token = self.factory.makeAccessToken(
186- owner=requester,
187- target=repository,
188- scopes=[AccessTokenScope.REPOSITORY_PUSH],
189- )
190- auth_params = _make_auth_params(
191- requester, access_token_id=removeSecurityProxy(token).id
192- )
193- self.assertSetsRepackData(repository, auth_params)
194-
195- def test_notify_set_repack_data_user_access_token_nonexistent(self):
196- requester = self.factory.makePerson()
197- repository = self.factory.makeGitRepository(owner=requester)
198- auth_params = _make_auth_params(requester, access_token_id=0)
199- self.assertFault(
200- faults.Unauthorized,
201- None,
202- "notify",
203- repository.getInternalPath(),
204- {"loose_object_count": 5, "pack_count": 2},
205- auth_params,
206- )
207-
208- def test_notify_set_repack_data_code_import(self):
209- # We set the repack data on a repository from a code import worker
210- # with a suitable macaroon.
211- self.pushConfig(
212- "launchpad", internal_macaroon_secret_key="some-secret"
213- )
214- machine = self.factory.makeCodeImportMachine(set_online=True)
215- code_imports = [
216- self.factory.makeCodeImport(
217- target_rcs_type=TargetRevisionControlSystems.GIT
218- )
219- for _ in range(2)
220- ]
221- with celebrity_logged_in("vcs_imports"):
222- jobs = [
223- self.factory.makeCodeImportJob(code_import=code_import)
224- for code_import in code_imports
225- ]
226- issuer = getUtility(IMacaroonIssuer, "code-import-job")
227- macaroons = [
228- removeSecurityProxy(issuer).issueMacaroon(job) for job in jobs
229- ]
230-
231- with celebrity_logged_in("vcs_imports"):
232- getUtility(ICodeImportJobWorkflow).startJob(jobs[0], machine)
233-
234- auth_params = _make_auth_params(
235- LAUNCHPAD_SERVICES, macaroon_raw=macaroons[0].serialize()
236- )
237- self.assertSetsRepackData(code_imports[0].git_repository, auth_params)
238-
239- auth_params = _make_auth_params(
240- LAUNCHPAD_SERVICES, macaroon_raw=macaroons[1].serialize()
241- )
242- self.assertFault(
243- faults.Unauthorized,
244- None,
245- "notify",
246- code_imports[0].git_repository.getInternalPath(),
247- {"loose_object_count": 5, "pack_count": 2},
248- auth_params,
249- )
250-
251- def test_notify_set_repack_data_private_code_import(self):
252- self.pushConfig(
253- "launchpad", internal_macaroon_secret_key="some-secret"
254- )
255- machine = self.factory.makeCodeImportMachine(set_online=True)
256- code_imports = [
257- self.factory.makeCodeImport(
258- target_rcs_type=TargetRevisionControlSystems.GIT
259- )
260- for _ in range(2)
261- ]
262- private_repository = code_imports[0].git_repository
263- path = private_repository.getInternalPath()
264- removeSecurityProxy(private_repository).transitionToInformationType(
265- InformationType.PRIVATESECURITY, private_repository.owner
266- )
267- with celebrity_logged_in("vcs_imports"):
268- jobs = [
269- self.factory.makeCodeImportJob(code_import=code_import)
270- for code_import in code_imports
271- ]
272- issuer = getUtility(IMacaroonIssuer, "code-import-job")
273- macaroons = [
274- removeSecurityProxy(issuer).issueMacaroon(job) for job in jobs
275- ]
276-
277- with celebrity_logged_in("vcs_imports"):
278- getUtility(ICodeImportJobWorkflow).startJob(jobs[0], machine)
279-
280- auth_params = _make_auth_params(
281- LAUNCHPAD_SERVICES, macaroon_raw=macaroons[0].serialize()
282- )
283- self.assertSetsRepackData(code_imports[0].git_repository, auth_params)
284-
285- auth_params = _make_auth_params(
286- LAUNCHPAD_SERVICES, macaroon_raw=macaroons[1].serialize()
287- )
288- self.assertFault(
289- faults.Unauthorized,
290- None,
291- "notify",
292- path,
293- {"loose_object_count": 5, "pack_count": 2},
294- auth_params,
295- )
296+ self.assertEqual(5, repository.loose_object_count)
297+ self.assertEqual(2, repository.pack_count)
298+ self.assertBetween(start_time, repository.date_last_scanned, end_time)
299
300 def test_authenticateWithPassword(self):
301 self.assertFault(

Subscribers

People subscribed via source and target branches

to status/vote changes: