Merge lp:~cjwatson/launchpad/fix-git-authenticateWithPassword into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 19064
Proposed branch: lp:~cjwatson/launchpad/fix-git-authenticateWithPassword
Merge into: lp:launchpad
Diff against target: 342 lines (+116/-62)
3 files modified
lib/lp/code/interfaces/gitapi.py (+7/-2)
lib/lp/code/xmlrpc/git.py (+12/-3)
lib/lp/code/xmlrpc/tests/test_git.py (+97/-57)
To merge this branch: bzr merge lp:~cjwatson/launchpad/fix-git-authenticateWithPassword
Reviewer Review Type Date Requested Status
Tony Simpson (community) Approve
Launchpad code reviewers Pending
Review via email: mp+373332@code.launchpad.net

Commit message

Fix XML-RPC publication of IGitAPI.authenticateWithPassword.

Description of the change

Zope's XML-RPC publication machinery was confused by the return_fault decorator, and published a method taking zero arguments. Pushing this decorator down a layer avoids that problem.

To test this, I needed to refactor the tests to call the API under test via an XML-RPC ServerProxy (which I probably should have done from the start anyway). Since this has the effect of serialising and deserialising any fault that's raised, I also had to rearrange how they test for faults.

To post a comment you must log in.
Revision history for this message
Tony Simpson (tonysimpson) wrote :

Looks good to me!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/interfaces/gitapi.py'
2--- lib/lp/code/interfaces/gitapi.py 2018-10-22 19:16:09 +0000
3+++ lib/lp/code/interfaces/gitapi.py 2019-09-27 15:51:36 +0000
4@@ -64,8 +64,13 @@
5 def authenticateWithPassword(username, password):
6 """Authenticate a user by username and password.
7
8- :returns: An `Unauthorized` fault, as password authentication is
9- not yet supported.
10+ This currently only works when using macaroon authentication.
11+
12+ :returns: An `Unauthorized` fault if the username and password do
13+ not match; otherwise, a dict containing a "uid" (for a real
14+ user) or "user" (for internal services) key indicating the
15+ authenticated principal, and possibly "macaroon" with a macaroon
16+ that requires further authorisation by other methods.
17 """
18
19 def checkRefPermissions(translated_paths, ref_paths, auth_params):
20
21=== modified file 'lib/lp/code/xmlrpc/git.py'
22--- lib/lp/code/xmlrpc/git.py 2019-09-10 09:58:52 +0000
23+++ lib/lp/code/xmlrpc/git.py 2019-09-27 15:51:36 +0000
24@@ -427,8 +427,13 @@
25 removeSecurityProxy(repository))
26
27 @return_fault
28- def authenticateWithPassword(self, username, password):
29- """See `IGitAPI`."""
30+ def _authenticateWithPassword(self, username, password):
31+ """Authenticate a user by username and password.
32+
33+ This is a separate method from `authenticateWithPassword` because
34+ otherwise Zope's XML-RPC publication machinery gets confused by the
35+ decorator and publishes a method that takes zero arguments.
36+ """
37 user = getUtility(IPersonSet).getByName(username) if username else None
38 verified = self._verifyMacaroon(password, user=user)
39 if verified:
40@@ -439,7 +444,11 @@
41 auth_params["user"] = LAUNCHPAD_SERVICES
42 return auth_params
43 # Only macaroons are supported for password authentication.
44- return faults.Unauthorized()
45+ raise faults.Unauthorized()
46+
47+ def authenticateWithPassword(self, username, password):
48+ """See `IGitAPI`."""
49+ return self._authenticateWithPassword(username, password)
50
51 def _renderPermissions(self, set_of_permissions):
52 """Render a set of permission strings for XML-RPC output."""
53
54=== modified file 'lib/lp/code/xmlrpc/tests/test_git.py'
55--- lib/lp/code/xmlrpc/tests/test_git.py 2019-09-05 11:29:00 +0000
56+++ lib/lp/code/xmlrpc/tests/test_git.py 2019-09-27 15:51:36 +0000
57@@ -6,6 +6,7 @@
58 __metaclass__ = type
59
60 from pymacaroons import Macaroon
61+import six
62 from six.moves import xmlrpc_client
63 from testtools.matchers import (
64 Equals,
65@@ -37,7 +38,6 @@
66 IGitRepositorySet,
67 )
68 from lp.code.tests.helpers import GitHostingFixture
69-from lp.code.xmlrpc.git import GitAPI
70 from lp.registry.enums import TeamMembershipPolicy
71 from lp.services.config import config
72 from lp.services.features.testing import FeatureFixture
73@@ -62,6 +62,7 @@
74 AppServerLayer,
75 LaunchpadFunctionalLayer,
76 )
77+from lp.testing.xmlrpc import XMLRPCTestTransport
78 from lp.xmlrpc import faults
79
80
81@@ -108,24 +109,58 @@
82 return ok
83
84
85+class MatchesFault(MatchesStructure):
86+ """Match an XML-RPC fault.
87+
88+ This can be given either::
89+
90+ - a subclass of LaunchpadFault (matches only the fault code)
91+ - an instance of Fault (matches the fault code and the fault string
92+ from this instance exactly)
93+ """
94+
95+ def __init__(self, expected_fault):
96+ fault_matchers = {}
97+ if (isinstance(expected_fault, six.class_types) and
98+ issubclass(expected_fault, faults.LaunchpadFault)):
99+ fault_matchers["faultCode"] = Equals(expected_fault.error_code)
100+ else:
101+ fault_matchers["faultCode"] = Equals(expected_fault.faultCode)
102+ fault_string = expected_fault.faultString
103+ # XXX cjwatson 2019-09-27: InvalidBranchName.faultString is
104+ # bytes, so we need this to handle that case. Should it be?
105+ if not isinstance(fault_string, six.text_type):
106+ fault_string = fault_string.decode("UTF-8")
107+ fault_matchers["faultString"] = Equals(fault_string)
108+ super(MatchesFault, self).__init__(**fault_matchers)
109+
110+
111 class TestGitAPIMixin:
112 """Helper methods for `IGitAPI` tests, and security-relevant tests."""
113
114 def setUp(self):
115 super(TestGitAPIMixin, self).setUp()
116- self.git_api = GitAPI(None, None)
117+ self.git_api = xmlrpc_client.ServerProxy(
118+ "http://xmlrpc-private.launchpad.test:8087/git",
119+ transport=XMLRPCTestTransport())
120 self.hosting_fixture = self.useFixture(GitHostingFixture())
121 self.repository_set = getUtility(IGitRepositorySet)
122
123+ def assertFault(self, expected_fault, func, *args, **kwargs):
124+ """Assert that a call raises the expected fault."""
125+ fault = self.assertRaises(xmlrpc_client.Fault, func, *args, **kwargs)
126+ self.assertThat(fault, MatchesFault(expected_fault))
127+ return fault
128+
129 def assertGitRepositoryNotFound(self, requester, path, permission="read",
130 can_authenticate=False, macaroon_raw=None):
131 """Assert that the given path cannot be translated."""
132 auth_params = _make_auth_params(
133 requester, can_authenticate=can_authenticate,
134 macaroon_raw=macaroon_raw)
135- fault = self.git_api.translatePath(path, permission, auth_params)
136- self.assertEqual(
137- faults.GitRepositoryNotFound(path.strip("/")), fault)
138+ self.assertFault(
139+ faults.GitRepositoryNotFound(path.strip("/")),
140+ self.git_api.translatePath, path, permission, auth_params)
141
142 def assertPermissionDenied(self, requester, path,
143 message="Permission denied.",
144@@ -135,8 +170,9 @@
145 auth_params = _make_auth_params(
146 requester, can_authenticate=can_authenticate,
147 macaroon_raw=macaroon_raw)
148- fault = self.git_api.translatePath(path, permission, auth_params)
149- self.assertEqual(faults.PermissionDenied(message), fault)
150+ self.assertFault(
151+ faults.PermissionDenied(message),
152+ self.git_api.translatePath, path, permission, auth_params)
153
154 def assertUnauthorized(self, requester, path,
155 message="Authorisation required.",
156@@ -146,16 +182,18 @@
157 auth_params = _make_auth_params(
158 requester, can_authenticate=can_authenticate,
159 macaroon_raw=macaroon_raw)
160- fault = self.git_api.translatePath(path, permission, auth_params)
161- self.assertEqual(faults.Unauthorized(message), fault)
162+ self.assertFault(
163+ faults.Unauthorized(message),
164+ self.git_api.translatePath, path, permission, auth_params)
165
166 def assertNotFound(self, requester, path, message, permission="read",
167 can_authenticate=False):
168 """Assert that looking at the given path returns NotFound."""
169 auth_params = _make_auth_params(
170 requester, can_authenticate=can_authenticate)
171- fault = self.git_api.translatePath(path, permission, auth_params)
172- self.assertEqual(faults.NotFound(message), fault)
173+ self.assertFault(
174+ faults.NotFound(message),
175+ self.git_api.translatePath, path, permission, auth_params)
176
177 def assertInvalidSourcePackageName(self, requester, path, name,
178 permission="read",
179@@ -164,24 +202,27 @@
180 InvalidSourcePackageName."""
181 auth_params = _make_auth_params(
182 requester, can_authenticate=can_authenticate)
183- fault = self.git_api.translatePath(path, permission, auth_params)
184- self.assertEqual(faults.InvalidSourcePackageName(name), fault)
185+ self.assertFault(
186+ faults.InvalidSourcePackageName(name),
187+ self.git_api.translatePath, path, permission, auth_params)
188
189 def assertInvalidBranchName(self, requester, path, message,
190 permission="read", can_authenticate=False):
191 """Assert that looking at the given path returns InvalidBranchName."""
192 auth_params = _make_auth_params(
193 requester, can_authenticate=can_authenticate)
194- fault = self.git_api.translatePath(path, permission, auth_params)
195- self.assertEqual(faults.InvalidBranchName(Exception(message)), fault)
196+ self.assertFault(
197+ faults.InvalidBranchName(Exception(message)),
198+ self.git_api.translatePath, path, permission, auth_params)
199
200 def assertOopsOccurred(self, requester, path,
201 permission="read", can_authenticate=False):
202 """Assert that looking at the given path OOPSes."""
203 auth_params = _make_auth_params(
204 requester, can_authenticate=can_authenticate)
205- fault = self.git_api.translatePath(path, permission, auth_params)
206- self.assertIsInstance(fault, faults.OopsOccurred)
207+ fault = self.assertFault(
208+ faults.OopsOccurred,
209+ self.git_api.translatePath, path, permission, auth_params)
210 prefix = (
211 "An unexpected error has occurred while creating a Git "
212 "repository. Please report a Launchpad bug and quote: ")
213@@ -551,10 +592,10 @@
214
215 def test_checkRefPermissions_nonexistent_repository(self):
216 requester = self.factory.makePerson()
217- self.assertEqual(
218+ self.assertFault(
219 faults.GitRepositoryNotFound("nonexistent"),
220- self.git_api.checkRefPermissions(
221- "nonexistent", [], {"uid": requester.id}))
222+ self.git_api.checkRefPermissions,
223+ "nonexistent", [], {"uid": requester.id})
224
225
226 class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
227@@ -1136,8 +1177,7 @@
228 def test_notify_missing_repository(self):
229 # A notify call on a non-existent repository returns a fault and
230 # does not create a job.
231- fault = self.git_api.notify("10000")
232- self.assertIsInstance(fault, faults.NotFound)
233+ self.assertFault(faults.NotFound, self.git_api.notify, "10000")
234 job_source = getUtility(IGitRefScanJobSource)
235 self.assertEqual([], list(job_source.iterReady()))
236
237@@ -1153,9 +1193,9 @@
238 self.assertEqual(repository, job.repository)
239
240 def test_authenticateWithPassword(self):
241- self.assertIsInstance(
242- self.git_api.authenticateWithPassword('foo', 'bar'),
243- faults.Unauthorized)
244+ self.assertFault(
245+ faults.Unauthorized,
246+ self.git_api.authenticateWithPassword, "foo", "bar")
247
248 def test_authenticateWithPassword_code_import(self):
249 self.pushConfig(
250@@ -1170,13 +1210,13 @@
251 {"macaroon": macaroon.serialize(), "user": "+launchpad-services"},
252 self.git_api.authenticateWithPassword("", macaroon.serialize()))
253 other_macaroon = Macaroon(identifier="another", key="another-secret")
254- self.assertIsInstance(
255- self.git_api.authenticateWithPassword(
256- "", other_macaroon.serialize()),
257- faults.Unauthorized)
258- self.assertIsInstance(
259- self.git_api.authenticateWithPassword("", "nonsense"),
260- faults.Unauthorized)
261+ self.assertFault(
262+ faults.Unauthorized,
263+ self.git_api.authenticateWithPassword,
264+ "", other_macaroon.serialize())
265+ self.assertFault(
266+ faults.Unauthorized,
267+ self.git_api.authenticateWithPassword, "", "nonsense")
268
269 def test_authenticateWithPassword_private_snap_build(self):
270 self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
271@@ -1193,13 +1233,13 @@
272 {"macaroon": macaroon.serialize(), "user": "+launchpad-services"},
273 self.git_api.authenticateWithPassword("", macaroon.serialize()))
274 other_macaroon = Macaroon(identifier="another", key="another-secret")
275- self.assertIsInstance(
276- self.git_api.authenticateWithPassword(
277- "", other_macaroon.serialize()),
278- faults.Unauthorized)
279- self.assertIsInstance(
280- self.git_api.authenticateWithPassword("", "nonsense"),
281- faults.Unauthorized)
282+ self.assertFault(
283+ faults.Unauthorized,
284+ self.git_api.authenticateWithPassword,
285+ "", other_macaroon.serialize())
286+ self.assertFault(
287+ faults.Unauthorized,
288+ self.git_api.authenticateWithPassword, "", "nonsense")
289
290 def test_authenticateWithPassword_user_macaroon(self):
291 # A user with a suitable macaroon can authenticate using it, in
292@@ -1214,21 +1254,21 @@
293 {"macaroon": macaroon.serialize(), "uid": requester.id},
294 self.git_api.authenticateWithPassword(
295 requester.name, macaroon.serialize()))
296- self.assertIsInstance(
297- self.git_api.authenticateWithPassword("", macaroon.serialize()),
298- faults.Unauthorized)
299- self.assertIsInstance(
300- self.git_api.authenticateWithPassword(
301- "nonexistent", macaroon.serialize()),
302- faults.Unauthorized)
303+ self.assertFault(
304+ faults.Unauthorized,
305+ self.git_api.authenticateWithPassword, "", macaroon.serialize())
306+ self.assertFault(
307+ faults.Unauthorized,
308+ self.git_api.authenticateWithPassword,
309+ "nonexistent", macaroon.serialize())
310 other_macaroon = Macaroon(identifier="another", key="another-secret")
311- self.assertIsInstance(
312- self.git_api.authenticateWithPassword(
313- requester.name, other_macaroon.serialize()),
314- faults.Unauthorized)
315- self.assertIsInstance(
316- self.git_api.authenticateWithPassword(requester.name, "nonsense"),
317- faults.Unauthorized)
318+ self.assertFault(
319+ faults.Unauthorized,
320+ self.git_api.authenticateWithPassword,
321+ requester.name, other_macaroon.serialize())
322+ self.assertFault(
323+ faults.Unauthorized,
324+ self.git_api.authenticateWithPassword, requester.name, "nonsense")
325
326 def test_authenticateWithPassword_user_mismatch(self):
327 # authenticateWithPassword refuses macaroons in the case where the
328@@ -1267,10 +1307,10 @@
329 name = (
330 requester if requester == LAUNCHPAD_SERVICES
331 else requester.name)
332- self.assertIsInstance(
333- self.git_api.authenticateWithPassword(
334- name, macaroon.serialize()),
335- faults.Unauthorized)
336+ self.assertFault(
337+ faults.Unauthorized,
338+ self.git_api.authenticateWithPassword,
339+ name, macaroon.serialize())
340
341 def test_checkRefPermissions_code_import(self):
342 # A code import worker with a suitable macaroon has repository owner