Merge lp:~cjwatson/launchpad/codeimport-git-auth into lp:launchpad
- codeimport-git-auth
- Merge into devel
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | 18229 |
Proposed branch: | lp:~cjwatson/launchpad/codeimport-git-auth |
Merge into: | lp:launchpad |
Diff against target: |
596 lines (+353/-24) 7 files modified
lib/lp/code/configure.zcml (+9/-0) lib/lp/code/model/codeimportjob.py (+54/-0) lib/lp/code/model/tests/test_codeimportjob.py (+100/-1) lib/lp/code/xmlrpc/git.py (+61/-14) lib/lp/code/xmlrpc/tests/test_git.py (+81/-9) lib/lp/services/config/schema-lazr.conf (+3/-0) lib/lp/services/macaroons/interfaces.py (+45/-0) |
To merge this branch: | bzr merge lp:~cjwatson/launchpad/codeimport-git-auth |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+307968@code.launchpad.net |
Commit message
Allow pushing to Git repositories associated with running code import jobs using macaroon credentials.
Description of the change
Allow pushing to Git repositories associated with running code import jobs using macaroon credentials.
We don't actually issue suitable macaroons as yet outside the test suite; that will come in a later branch.
I've tried to design the IMacaroonIssuer interface so that it can be useful for some other similar applications (e.g. granting access tokens to builders), but it's provisional and I entirely expect it to need to change when it's actually battle-tested for that kind of thing.
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Revision history for this message
Colin Watson (cjwatson) : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/code/configure.zcml' |
2 | --- lib/lp/code/configure.zcml 2016-10-03 17:00:56 +0000 |
3 | +++ lib/lp/code/configure.zcml 2016-10-12 12:42:25 +0000 |
4 | @@ -729,6 +729,15 @@ |
5 | reclaimJob"/> |
6 | </securedutility> |
7 | |
8 | + <!-- CodeImportJobMacaroonIssuer --> |
9 | + |
10 | + <securedutility |
11 | + class="lp.code.model.codeimportjob.CodeImportJobMacaroonIssuer" |
12 | + provides="lp.services.macaroons.interfaces.IMacaroonIssuer" |
13 | + name="code-import-job"> |
14 | + <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/> |
15 | + </securedutility> |
16 | + |
17 | <!-- CodeImportEvent --> |
18 | |
19 | <class class="lp.code.model.codeimportevent.CodeImportEvent"> |
20 | |
21 | === modified file 'lib/lp/code/model/codeimportjob.py' |
22 | --- lib/lp/code/model/codeimportjob.py 2016-10-03 17:00:56 +0000 |
23 | +++ lib/lp/code/model/codeimportjob.py 2016-10-12 12:42:25 +0000 |
24 | @@ -12,6 +12,10 @@ |
25 | |
26 | import datetime |
27 | |
28 | +from pymacaroons import ( |
29 | + Macaroon, |
30 | + Verifier, |
31 | + ) |
32 | from sqlobject import ( |
33 | ForeignKey, |
34 | IntCol, |
35 | @@ -27,7 +31,9 @@ |
36 | CodeImportMachineState, |
37 | CodeImportResultStatus, |
38 | CodeImportReviewStatus, |
39 | + GitRepositoryType, |
40 | ) |
41 | +from lp.code.interfaces.codeimport import ICodeImportSet |
42 | from lp.code.interfaces.codeimportevent import ICodeImportEventSet |
43 | from lp.code.interfaces.codeimportjob import ( |
44 | ICodeImportJob, |
45 | @@ -37,6 +43,7 @@ |
46 | ) |
47 | from lp.code.interfaces.codeimportmachine import ICodeImportMachineSet |
48 | from lp.code.interfaces.codeimportresult import ICodeImportResultSet |
49 | +from lp.code.interfaces.gitrepository import IGitRepository |
50 | from lp.code.model.codeimportresult import CodeImportResult |
51 | from lp.registry.interfaces.person import validate_public_person |
52 | from lp.services.config import config |
53 | @@ -48,6 +55,7 @@ |
54 | SQLBase, |
55 | sqlvalues, |
56 | ) |
57 | +from lp.services.macaroons.interfaces import IMacaroonIssuer |
58 | |
59 | |
60 | @implementer(ICodeImportJob) |
61 | @@ -340,3 +348,49 @@ |
62 | # 4) |
63 | getUtility(ICodeImportEventSet).newReclaim( |
64 | code_import, machine, job_id) |
65 | + |
66 | + |
67 | +@implementer(IMacaroonIssuer) |
68 | +class CodeImportJobMacaroonIssuer: |
69 | + |
70 | + @property |
71 | + def _root_secret(self): |
72 | + secret = config.codeimport.macaroon_secret_key |
73 | + if not secret: |
74 | + raise RuntimeError( |
75 | + "codeimport.macaroon_secret_key not configured.") |
76 | + return secret |
77 | + |
78 | + def issueMacaroon(self, context): |
79 | + """See `IMacaroonIssuer`.""" |
80 | + assert context.code_import.git_repository is not None |
81 | + macaroon = Macaroon( |
82 | + location=config.vhost.mainsite.hostname, |
83 | + identifier="code-import-job", key=self._root_secret) |
84 | + macaroon.add_first_party_caveat("code-import-job %s" % context.id) |
85 | + return macaroon |
86 | + |
87 | + def checkMacaroonIssuer(self, macaroon): |
88 | + """See `IMacaroonIssuer`.""" |
89 | + if macaroon.location != config.vhost.mainsite.hostname: |
90 | + return False |
91 | + try: |
92 | + verifier = Verifier() |
93 | + verifier.satisfy_general( |
94 | + lambda caveat: caveat.startswith("code-import-job ")) |
95 | + return verifier.verify(macaroon, self._root_secret) |
96 | + except Exception: |
97 | + return False |
98 | + |
99 | + def verifyMacaroon(self, macaroon, context): |
100 | + """See `IMacaroonIssuer`.""" |
101 | + if not self.checkMacaroonIssuer(macaroon): |
102 | + return False |
103 | + try: |
104 | + verifier = Verifier() |
105 | + verifier.satisfy_exact("code-import-job %s" % context.id) |
106 | + return ( |
107 | + verifier.verify(macaroon, self._root_secret) and |
108 | + context.state == CodeImportJobState.RUNNING) |
109 | + except Exception: |
110 | + return False |
111 | |
112 | === modified file 'lib/lp/code/model/tests/test_codeimportjob.py' |
113 | --- lib/lp/code/model/tests/test_codeimportjob.py 2015-10-19 10:56:16 +0000 |
114 | +++ lib/lp/code/model/tests/test_codeimportjob.py 2016-10-12 12:42:25 +0000 |
115 | @@ -1,4 +1,4 @@ |
116 | -# Copyright 2009-2011 Canonical Ltd. This software is licensed under the |
117 | +# Copyright 2009-2016 Canonical Ltd. This software is licensed under the |
118 | # GNU Affero General Public License version 3 (see the file LICENSE). |
119 | |
120 | """Unit tests for CodeImportJob and CodeImportJobWorkflow.""" |
121 | @@ -13,7 +13,12 @@ |
122 | import StringIO |
123 | import unittest |
124 | |
125 | +from pymacaroons import Macaroon |
126 | from pytz import UTC |
127 | +from testtools.matchers import ( |
128 | + MatchesListwise, |
129 | + MatchesStructure, |
130 | + ) |
131 | import transaction |
132 | from zope.component import getUtility |
133 | from zope.security.proxy import removeSecurityProxy |
134 | @@ -23,6 +28,7 @@ |
135 | CodeImportJobState, |
136 | CodeImportResultStatus, |
137 | CodeImportReviewStatus, |
138 | + TargetRevisionControlSystems, |
139 | ) |
140 | from lp.code.interfaces.codeimport import ICodeImportSet |
141 | from lp.code.interfaces.codeimportevent import ICodeImportEventSet |
142 | @@ -41,6 +47,7 @@ |
143 | from lp.services.database.constants import UTC_NOW |
144 | from lp.services.librarian.interfaces import ILibraryFileAliasSet |
145 | from lp.services.librarian.interfaces.client import ILibrarianClient |
146 | +from lp.services.macaroons.interfaces import IMacaroonIssuer |
147 | from lp.services.webapp import canonical_url |
148 | from lp.testing import ( |
149 | ANONYMOUS, |
150 | @@ -1144,5 +1151,97 @@ |
151 | get_feedback_messages(user_browser.contents)) |
152 | |
153 | |
154 | +class TestCodeImportJobMacaroonIssuer(TestCaseWithFactory): |
155 | + """Test CodeImportJob macaroon issuing and verification.""" |
156 | + |
157 | + layer = DatabaseFunctionalLayer |
158 | + |
159 | + def setUp(self): |
160 | + super(TestCodeImportJobMacaroonIssuer, self).setUp() |
161 | + login_for_code_imports() |
162 | + self.pushConfig("codeimport", macaroon_secret_key="some-secret") |
163 | + |
164 | + def makeJob(self, target_rcs_type=TargetRevisionControlSystems.GIT): |
165 | + code_import = self.factory.makeCodeImport( |
166 | + target_rcs_type=target_rcs_type) |
167 | + return self.factory.makeCodeImportJob(code_import=code_import) |
168 | + |
169 | + def test_issueMacaroon_refuses_branch(self): |
170 | + job = self.makeJob(target_rcs_type=TargetRevisionControlSystems.BZR) |
171 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
172 | + self.assertRaises( |
173 | + AssertionError, removeSecurityProxy(issuer).issueMacaroon, job) |
174 | + |
175 | + def test_issueMacaroon_good(self): |
176 | + job = self.makeJob() |
177 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
178 | + macaroon = removeSecurityProxy(issuer).issueMacaroon(job) |
179 | + self.assertEqual("launchpad.dev", macaroon.location) |
180 | + self.assertEqual("code-import-job", macaroon.identifier) |
181 | + self.assertThat(macaroon.caveats, MatchesListwise([ |
182 | + MatchesStructure.byEquality( |
183 | + caveat_id="code-import-job %s" % job.id), |
184 | + ])) |
185 | + |
186 | + def test_checkMacaroonIssuer_good(self): |
187 | + job = self.makeJob() |
188 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
189 | + macaroon = removeSecurityProxy(issuer).issueMacaroon(job) |
190 | + self.assertTrue(issuer.checkMacaroonIssuer(macaroon)) |
191 | + |
192 | + def test_checkMacaroonIssuer_wrong_location(self): |
193 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
194 | + macaroon = Macaroon( |
195 | + location="another-location", |
196 | + key=removeSecurityProxy(issuer)._root_secret) |
197 | + self.assertFalse(issuer.checkMacaroonIssuer(macaroon)) |
198 | + |
199 | + def test_checkMacaroonIssuer_wrong_key(self): |
200 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
201 | + macaroon = Macaroon( |
202 | + location=config.vhost.mainsite.hostname, key="another-secret") |
203 | + self.assertFalse(issuer.checkMacaroonIssuer(macaroon)) |
204 | + |
205 | + def test_verifyMacaroon_good(self): |
206 | + machine = self.factory.makeCodeImportMachine(set_online=True) |
207 | + job = self.makeJob() |
208 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
209 | + getUtility(ICodeImportJobWorkflow).startJob(job, machine) |
210 | + macaroon = removeSecurityProxy(issuer).issueMacaroon(job) |
211 | + self.assertTrue(issuer.verifyMacaroon(macaroon, job)) |
212 | + |
213 | + def test_verifyMacaroon_wrong_location(self): |
214 | + machine = self.factory.makeCodeImportMachine(set_online=True) |
215 | + job = self.makeJob() |
216 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
217 | + getUtility(ICodeImportJobWorkflow).startJob(job, machine) |
218 | + macaroon = Macaroon( |
219 | + location="another-location", |
220 | + key=removeSecurityProxy(issuer)._root_secret) |
221 | + self.assertFalse(issuer.verifyMacaroon(macaroon, job)) |
222 | + |
223 | + def test_verifyMacaroon_wrong_key(self): |
224 | + machine = self.factory.makeCodeImportMachine(set_online=True) |
225 | + job = self.makeJob() |
226 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
227 | + getUtility(ICodeImportJobWorkflow).startJob(job, machine) |
228 | + macaroon = Macaroon( |
229 | + location=config.vhost.mainsite.hostname, key="another-secret") |
230 | + self.assertFalse(issuer.verifyMacaroon(macaroon, job)) |
231 | + |
232 | + def test_verifyMacaroon_not_running(self): |
233 | + job = self.makeJob() |
234 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
235 | + macaroon = removeSecurityProxy(issuer).issueMacaroon(job) |
236 | + self.assertFalse(issuer.verifyMacaroon(macaroon, job)) |
237 | + |
238 | + def test_verifyMacaroon_wrong_job(self): |
239 | + job = self.makeJob() |
240 | + other_job = self.factory.makeCodeImportJob(code_import=job.code_import) |
241 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
242 | + macaroon = removeSecurityProxy(issuer).issueMacaroon(other_job) |
243 | + self.assertFalse(issuer.verifyMacaroon(macaroon, job)) |
244 | + |
245 | + |
246 | def test_suite(): |
247 | return unittest.TestLoader().loadTestsFromName(__name__) |
248 | |
249 | === modified file 'lib/lp/code/xmlrpc/git.py' |
250 | --- lib/lp/code/xmlrpc/git.py 2016-10-06 20:37:39 +0000 |
251 | +++ lib/lp/code/xmlrpc/git.py 2016-10-12 12:42:25 +0000 |
252 | @@ -10,9 +10,13 @@ |
253 | |
254 | import sys |
255 | |
256 | +from pymacaroons import Macaroon |
257 | from storm.store import Store |
258 | import transaction |
259 | -from zope.component import getUtility |
260 | +from zope.component import ( |
261 | + ComponentLookupError, |
262 | + getUtility, |
263 | + ) |
264 | from zope.error.interfaces import IErrorReportingUtility |
265 | from zope.interface import implementer |
266 | from zope.security.interfaces import Unauthorized |
267 | @@ -30,6 +34,7 @@ |
268 | InvalidNamespace, |
269 | ) |
270 | from lp.code.interfaces.codehosting import LAUNCHPAD_ANONYMOUS |
271 | +from lp.code.interfaces.codeimport import ICodeImportSet |
272 | from lp.code.interfaces.gitapi import IGitAPI |
273 | from lp.code.interfaces.githosting import IGitHostingClient |
274 | from lp.code.interfaces.gitjob import IGitRefScanJobSource |
275 | @@ -53,6 +58,7 @@ |
276 | NoSuchProduct, |
277 | ) |
278 | from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet |
279 | +from lp.services.macaroons.interfaces import IMacaroonIssuer |
280 | from lp.services.webapp import LaunchpadXMLRPCView |
281 | from lp.services.webapp.authorization import check_permission |
282 | from lp.services.webapp.errorlog import ScriptRequest |
283 | @@ -68,22 +74,58 @@ |
284 | super(GitAPI, self).__init__(*args, **kwargs) |
285 | self.repository_set = getUtility(IGitRepositorySet) |
286 | |
287 | - def _performLookup(self, path): |
288 | + def _verifyMacaroon(self, macaroon_raw, repository=None): |
289 | + try: |
290 | + macaroon = Macaroon.deserialize(macaroon_raw) |
291 | + except Exception: |
292 | + return False |
293 | + try: |
294 | + issuer = getUtility(IMacaroonIssuer, macaroon.identifier) |
295 | + except ComponentLookupError: |
296 | + return False |
297 | + if repository is not None: |
298 | + if repository.repository_type != GitRepositoryType.IMPORTED: |
299 | + return False |
300 | + code_import = getUtility(ICodeImportSet).getByGitRepository( |
301 | + repository) |
302 | + if code_import is None: |
303 | + return False |
304 | + job = code_import.import_job |
305 | + if job is None: |
306 | + return False |
307 | + return issuer.verifyMacaroon(macaroon, job) |
308 | + else: |
309 | + return issuer.checkMacaroonIssuer(macaroon) |
310 | + |
311 | + def _performLookup(self, path, auth_params): |
312 | repository, extra_path = getUtility(IGitLookup).getByPath(path) |
313 | if repository is None: |
314 | return None |
315 | - try: |
316 | - hosting_path = repository.getInternalPath() |
317 | - except Unauthorized: |
318 | - return None |
319 | - writable = ( |
320 | - repository.repository_type == GitRepositoryType.HOSTED and |
321 | - check_permission("launchpad.Edit", repository)) |
322 | + macaroon_raw = auth_params.get("macaroon") |
323 | + naked_repository = removeSecurityProxy(repository) |
324 | + if (macaroon_raw is not None and |
325 | + self._verifyMacaroon(macaroon_raw, naked_repository)): |
326 | + # The authentication parameters specifically grant access to |
327 | + # this repository, so we can bypass other checks. |
328 | + # For the time being, this only works for code imports. |
329 | + assert repository.repository_type == GitRepositoryType.IMPORTED |
330 | + hosting_path = naked_repository.getInternalPath() |
331 | + writable = True |
332 | + private = naked_repository.private |
333 | + else: |
334 | + try: |
335 | + hosting_path = repository.getInternalPath() |
336 | + except Unauthorized: |
337 | + return None |
338 | + writable = ( |
339 | + repository.repository_type == GitRepositoryType.HOSTED and |
340 | + check_permission("launchpad.Edit", repository)) |
341 | + private = repository.private |
342 | return { |
343 | "path": hosting_path, |
344 | "writable": writable, |
345 | "trailing": extra_path, |
346 | - "private": repository.private, |
347 | + "private": private, |
348 | } |
349 | |
350 | def _getGitNamespaceExtras(self, path, requester): |
351 | @@ -233,11 +275,11 @@ |
352 | if requester == LAUNCHPAD_ANONYMOUS: |
353 | requester = None |
354 | try: |
355 | - result = self._performLookup(path) |
356 | + result = self._performLookup(path, auth_params) |
357 | if (result is None and requester is not None and |
358 | permission == "write"): |
359 | self._createRepository(requester, path) |
360 | - result = self._performLookup(path) |
361 | + result = self._performLookup(path, auth_params) |
362 | if result is None: |
363 | raise faults.GitRepositoryNotFound(path) |
364 | if permission != "read" and not result["writable"]: |
365 | @@ -286,5 +328,10 @@ |
366 | |
367 | def authenticateWithPassword(self, username, password): |
368 | """See `IGitAPI`.""" |
369 | - # Password authentication isn't supported yet. |
370 | - return faults.Unauthorized() |
371 | + # XXX cjwatson 2016-10-06: We only support free-floating macaroons |
372 | + # at the moment, not ones bound to a user. |
373 | + if not username and self._verifyMacaroon(password): |
374 | + return {"macaroon": password} |
375 | + else: |
376 | + # Only macaroons are supported for password authentication. |
377 | + return faults.Unauthorized() |
378 | |
379 | === modified file 'lib/lp/code/xmlrpc/tests/test_git.py' |
380 | --- lib/lp/code/xmlrpc/tests/test_git.py 2016-10-06 11:17:05 +0000 |
381 | +++ lib/lp/code/xmlrpc/tests/test_git.py 2016-10-12 12:42:25 +0000 |
382 | @@ -5,6 +5,7 @@ |
383 | |
384 | __metaclass__ = type |
385 | |
386 | +from pymacaroons import Macaroon |
387 | from testscenarios import ( |
388 | load_tests_apply_scenarios, |
389 | WithScenarios, |
390 | @@ -13,8 +14,12 @@ |
391 | from zope.security.proxy import removeSecurityProxy |
392 | |
393 | from lp.app.enums import InformationType |
394 | -from lp.code.enums import GitRepositoryType |
395 | +from lp.code.enums import ( |
396 | + GitRepositoryType, |
397 | + TargetRevisionControlSystems, |
398 | + ) |
399 | from lp.code.errors import GitRepositoryCreationFault |
400 | +from lp.code.interfaces.codeimportjob import ICodeImportJobWorkflow |
401 | from lp.code.interfaces.gitcollection import IAllGitRepositories |
402 | from lp.code.interfaces.gitjob import IGitRefScanJobSource |
403 | from lp.code.interfaces.gitrepository import ( |
404 | @@ -24,10 +29,13 @@ |
405 | from lp.code.tests.helpers import GitHostingFixture |
406 | from lp.code.xmlrpc.git import GitAPI |
407 | from lp.registry.enums import TeamMembershipPolicy |
408 | +from lp.services.config import config |
409 | +from lp.services.macaroons.interfaces import IMacaroonIssuer |
410 | from lp.services.webapp.escaping import html_escape |
411 | from lp.testing import ( |
412 | admin_logged_in, |
413 | ANONYMOUS, |
414 | + celebrity_logged_in, |
415 | login, |
416 | person_logged_in, |
417 | TestCaseWithFactory, |
418 | @@ -74,13 +82,15 @@ |
419 | |
420 | def assertPermissionDenied(self, requester, path, |
421 | message="Permission denied.", |
422 | - permission="read", can_authenticate=False): |
423 | + permission="read", can_authenticate=False, |
424 | + macaroon_raw=None): |
425 | """Assert that looking at the given path returns PermissionDenied.""" |
426 | if requester is not None: |
427 | requester = requester.id |
428 | - fault = self._translatePath( |
429 | - path, permission, |
430 | - {"uid": requester, "can-authenticate": can_authenticate}) |
431 | + auth_params = {"uid": requester, "can-authenticate": can_authenticate} |
432 | + if macaroon_raw is not None: |
433 | + auth_params["macaroon"] = macaroon_raw |
434 | + fault = self._translatePath(path, permission, auth_params) |
435 | self.assertEqual(faults.PermissionDenied(message), fault) |
436 | |
437 | def assertUnauthorized(self, requester, path, |
438 | @@ -143,12 +153,13 @@ |
439 | |
440 | def assertTranslates(self, requester, path, repository, writable, |
441 | permission="read", can_authenticate=False, |
442 | - trailing="", private=False): |
443 | + macaroon_raw=None, trailing="", private=False): |
444 | if requester is not None: |
445 | requester = requester.id |
446 | - translation = self._translatePath( |
447 | - path, permission, |
448 | - {"uid": requester, "can-authenticate": can_authenticate}) |
449 | + auth_params = {"uid": requester, "can-authenticate": can_authenticate} |
450 | + if macaroon_raw is not None: |
451 | + auth_params["macaroon"] = macaroon_raw |
452 | + translation = self._translatePath(path, permission, auth_params) |
453 | login(ANONYMOUS) |
454 | self.assertEqual( |
455 | {"path": repository.getInternalPath(), "writable": writable, |
456 | @@ -634,6 +645,47 @@ |
457 | "GitRepositoryCreationFault: nothing here", |
458 | self.oopses[0]["tb_text"]) |
459 | |
460 | + def test_translatePath_code_import(self): |
461 | + # A code import worker with a suitable macaroon can write to a |
462 | + # repository associated with a running code import job. |
463 | + self.pushConfig("codeimport", macaroon_secret_key="some-secret") |
464 | + machine = self.factory.makeCodeImportMachine(set_online=True) |
465 | + code_imports = [ |
466 | + self.factory.makeCodeImport( |
467 | + target_rcs_type=TargetRevisionControlSystems.GIT) |
468 | + for _ in range(2)] |
469 | + with celebrity_logged_in("vcs_imports"): |
470 | + jobs = [ |
471 | + self.factory.makeCodeImportJob(code_import=code_import) |
472 | + for code_import in code_imports] |
473 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
474 | + macaroons = [ |
475 | + removeSecurityProxy(issuer).issueMacaroon(job) for job in jobs] |
476 | + path = u"/%s" % code_imports[0].git_repository.unique_name |
477 | + self.assertPermissionDenied( |
478 | + None, path, permission="write", macaroon_raw=macaroons[0]) |
479 | + with celebrity_logged_in("vcs_imports"): |
480 | + getUtility(ICodeImportJobWorkflow).startJob(jobs[0], machine) |
481 | + # This only works with new-style passing of authentication parameters. |
482 | + if self.auth_params_dict: |
483 | + self.assertTranslates( |
484 | + None, path, code_imports[0].git_repository, True, |
485 | + permission="write", macaroon_raw=macaroons[0].serialize()) |
486 | + else: |
487 | + self.assertPermissionDenied( |
488 | + None, path, permission="write", |
489 | + macaroon_raw=macaroons[0].serialize()) |
490 | + self.assertPermissionDenied( |
491 | + None, path, permission="write", |
492 | + macaroon_raw=macaroons[1].serialize()) |
493 | + self.assertPermissionDenied( |
494 | + None, path, permission="write", |
495 | + macaroon_raw=Macaroon( |
496 | + location=config.vhost.mainsite.hostname, identifier="another", |
497 | + key="another-secret").serialize()) |
498 | + self.assertPermissionDenied( |
499 | + None, path, permission="write", macaroon_raw="nonsense") |
500 | + |
501 | def test_notify(self): |
502 | # The notify call creates a GitRefScanJob. |
503 | repository = self.factory.makeGitRepository() |
504 | @@ -666,6 +718,26 @@ |
505 | self.git_api.authenticateWithPassword('foo', 'bar'), |
506 | faults.Unauthorized) |
507 | |
508 | + def test_authenticateWithPassword_code_import(self): |
509 | + self.pushConfig("codeimport", macaroon_secret_key="some-secret") |
510 | + code_import = self.factory.makeCodeImport( |
511 | + target_rcs_type=TargetRevisionControlSystems.GIT) |
512 | + with celebrity_logged_in("vcs_imports"): |
513 | + job = self.factory.makeCodeImportJob(code_import=code_import) |
514 | + issuer = getUtility(IMacaroonIssuer, "code-import-job") |
515 | + macaroon = removeSecurityProxy(issuer).issueMacaroon(job) |
516 | + self.assertEqual( |
517 | + {"macaroon": macaroon.serialize()}, |
518 | + self.git_api.authenticateWithPassword("", macaroon.serialize())) |
519 | + other_macaroon = Macaroon(identifier="another", key="another-secret") |
520 | + self.assertIsInstance( |
521 | + self.git_api.authenticateWithPassword( |
522 | + "", other_macaroon.serialize()), |
523 | + faults.Unauthorized) |
524 | + self.assertIsInstance( |
525 | + self.git_api.authenticateWithPassword("", "nonsense"), |
526 | + faults.Unauthorized) |
527 | + |
528 | |
529 | class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory): |
530 | """Slow tests for `IGitAPI`. |
531 | |
532 | === modified file 'lib/lp/services/config/schema-lazr.conf' |
533 | --- lib/lp/services/config/schema-lazr.conf 2016-10-04 11:19:07 +0000 |
534 | +++ lib/lp/services/config/schema-lazr.conf 2016-10-12 12:42:25 +0000 |
535 | @@ -416,6 +416,9 @@ |
536 | # Import only this many revisions from svn (via bzr-svn) at once. |
537 | svn_revisions_import_limit: 500 |
538 | |
539 | +# Secret key for macaroons used to grant git push permission to workers. |
540 | +macaroon_secret_key: |
541 | + |
542 | [codeimportdispatcher] |
543 | # The directory where the code import worker should be directed to |
544 | # store its logs. |
545 | |
546 | === added directory 'lib/lp/services/macaroons' |
547 | === added file 'lib/lp/services/macaroons/__init__.py' |
548 | === added file 'lib/lp/services/macaroons/interfaces.py' |
549 | --- lib/lp/services/macaroons/interfaces.py 1970-01-01 00:00:00 +0000 |
550 | +++ lib/lp/services/macaroons/interfaces.py 2016-10-12 12:42:25 +0000 |
551 | @@ -0,0 +1,45 @@ |
552 | +# Copyright 2016 Canonical Ltd. This software is licensed under the |
553 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
554 | + |
555 | +"""Interface to a policy for issuing and verifying macaroons.""" |
556 | + |
557 | +from __future__ import absolute_import, print_function, unicode_literals |
558 | + |
559 | +__metaclass__ = type |
560 | +__all__ = [ |
561 | + 'IMacaroonIssuer', |
562 | + ] |
563 | + |
564 | +from zope.interface import Interface |
565 | + |
566 | + |
567 | +class IMacaroonIssuerPublic(Interface): |
568 | + """Public interface to a policy for verifying macaroons.""" |
569 | + |
570 | + def checkMacaroonIssuer(macaroon): |
571 | + """Check that `macaroon` was issued by this issuer. |
572 | + |
573 | + This does not verify that the macaroon is valid for a given context, |
574 | + only that it could be valid for some context. Use this in the |
575 | + authentication part of an authentication/authorisation API. |
576 | + """ |
577 | + |
578 | + def verifyMacaroon(macaroon, context): |
579 | + """Verify that `macaroon` is valid for `context`. |
580 | + |
581 | + :param macaroon: A `Macaroon`. |
582 | + :param context: The context to check. |
583 | + :return: True if `macaroon` is valid for `context`, otherwise False. |
584 | + """ |
585 | + |
586 | + |
587 | +class IMacaroonIssuer(IMacaroonIssuerPublic): |
588 | + """Interface to a policy for issuing and verifying macaroons.""" |
589 | + |
590 | + def issueMacaroon(context): |
591 | + """Issue a macaroon for `context`. |
592 | + |
593 | + :param context: The context that the returned macaroon should relate |
594 | + to. |
595 | + :return: A macaroon. |
596 | + """ |