Merge lp:~cjwatson/launchpad/git-issue-access-tokens into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18954
Proposed branch: lp:~cjwatson/launchpad/git-issue-access-tokens
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/refactor-code-import-macaroons
Diff against target: 745 lines (+417/-36)
9 files modified
lib/lp/code/configure.zcml (+10/-1)
lib/lp/code/model/codeimportjob.py (+3/-3)
lib/lp/code/model/gitrepository.py (+101/-1)
lib/lp/code/model/tests/test_gitrepository.py (+233/-0)
lib/lp/services/authserver/tests/test_authserver.py (+3/-3)
lib/lp/services/macaroons/interfaces.py (+7/-2)
lib/lp/services/macaroons/model.py (+54/-20)
lib/lp/snappy/model/snapbuild.py (+3/-3)
lib/lp/soyuz/model/binarypackagebuild.py (+3/-3)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-issue-access-tokens
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+366052@code.launchpad.net

Commit message

Add a macaroon issuer for Git access tokens.

Description of the change

These aren't honoured by the XML-RPC API yet, and there's no way for users to actually request them. Those will come in subsequent branches.

The expiry/persistence policy will likely need to change in future, but the current expiry time is short enough that we should be able to evolve this without too much difficulty.

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) :
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
William Grant (wgrant) :

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 2018-10-16 15:52:00 +0000
3+++ lib/lp/code/configure.zcml 2019-05-01 16:44:22 +0000
4@@ -1,4 +1,4 @@
5-<!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the
6+<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the
7 GNU Affero General Public License version 3 (see the file LICENSE).
8 -->
9
10@@ -856,6 +856,15 @@
11 <allow interface="lp.code.interfaces.gitrepository.IGitRepositorySet" />
12 </securedutility>
13
14+ <!-- GitRepositoryMacaroonIssuer -->
15+
16+ <securedutility
17+ class="lp.code.model.gitrepository.GitRepositoryMacaroonIssuer"
18+ provides="lp.services.macaroons.interfaces.IMacaroonIssuer"
19+ name="git-repository">
20+ <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic" />
21+ </securedutility>
22+
23 <!-- GitRepositoryDelta -->
24
25 <class class="lp.code.adapters.gitrepository.GitRepositoryDelta">
26
27=== modified file 'lib/lp/code/model/codeimportjob.py'
28--- lib/lp/code/model/codeimportjob.py 2019-04-26 13:13:37 +0000
29+++ lib/lp/code/model/codeimportjob.py 2019-05-01 16:44:22 +0000
30@@ -428,14 +428,14 @@
31 "launchpad.internal_macaroon_secret_key not configured.")
32 return secret
33
34- def checkIssuingContext(self, context):
35+ def checkIssuingContext(self, context, **kwargs):
36 """See `MacaroonIssuerBase`."""
37 if context.code_import.git_repository is None:
38 raise BadMacaroonContext(
39 context, "context.code_import.git_repository is None")
40 return context.id
41
42- def checkVerificationContext(self, context):
43+ def checkVerificationContext(self, context, **kwargs):
44 """See `MacaroonIssuerBase`.
45
46 For verification, the context may be an `ICodeImportJob`, in which
47@@ -461,7 +461,7 @@
48 context, "%r is not in the RUNNING state." % context)
49 return context
50
51- def verifyPrimaryCaveat(self, caveat_value, context):
52+ def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
53 """See `MacaroonIssuerBase`."""
54 if context is None:
55 # We're only verifying that the macaroon could be valid for some
56
57=== modified file 'lib/lp/code/model/gitrepository.py'
58--- lib/lp/code/model/gitrepository.py 2019-04-02 08:12:27 +0000
59+++ lib/lp/code/model/gitrepository.py 2019-05-01 16:44:22 +0000
60@@ -13,7 +13,10 @@
61 defaultdict,
62 OrderedDict,
63 )
64-from datetime import datetime
65+from datetime import (
66+ datetime,
67+ timedelta,
68+ )
69 import email
70 from fnmatch import fnmatch
71 from functools import partial
72@@ -174,6 +177,7 @@
73 from lp.services.database.decoratedresultset import DecoratedResultSet
74 from lp.services.database.enumcol import EnumCol
75 from lp.services.database.interfaces import IStore
76+from lp.services.database.sqlbase import get_transaction_timestamp
77 from lp.services.database.stormbase import StormBase
78 from lp.services.database.stormexpr import (
79 Array,
80@@ -182,8 +186,12 @@
81 BulkUpdate,
82 Values,
83 )
84+from lp.services.features import getFeatureFlag
85+from lp.services.identity.interfaces.account import IAccountSet
86 from lp.services.job.interfaces.job import JobStatus
87 from lp.services.job.model.job import Job
88+from lp.services.macaroons.interfaces import IMacaroonIssuer
89+from lp.services.macaroons.model import MacaroonIssuerBase
90 from lp.services.mail.notificationrecipientset import NotificationRecipientSet
91 from lp.services.propertycache import (
92 cachedproperty,
93@@ -1764,6 +1772,98 @@
94 repository.project_id: repository for repository in repositories}
95
96
97+@implementer(IMacaroonIssuer)
98+class GitRepositoryMacaroonIssuer(MacaroonIssuerBase):
99+
100+ identifier = "git-repository"
101+ allow_multiple = {"lp.expires"}
102+
103+ _timestamp_format = "%Y-%m-%dT%H:%M:%S.%f"
104+
105+ def __init__(self):
106+ super(GitRepositoryMacaroonIssuer, self).__init__()
107+ self.checkers = {
108+ "lp.principal.openid-identifier": self.verifyOpenIDIdentifier,
109+ "lp.expires": self.verifyExpires,
110+ }
111+
112+ def checkIssuingContext(self, context, user=None, **kwargs):
113+ """See `MacaroonIssuerBase`.
114+
115+ For issuing, the context is an `IGitRepository`.
116+ """
117+ if user is None:
118+ raise Unauthorized(
119+ "git-repository macaroons may only be issued for a logged-in "
120+ "user.")
121+ if not IGitRepository.providedBy(context):
122+ raise ValueError("Cannot handle context %r." % context)
123+ return context.id
124+
125+ def issueMacaroon(self, context, user=None, **kwargs):
126+ """See `IMacaroonIssuer`."""
127+ macaroon = super(GitRepositoryMacaroonIssuer, self).issueMacaroon(
128+ context, user=user, **kwargs)
129+ naked_account = removeSecurityProxy(user).account
130+ macaroon.add_first_party_caveat(
131+ "lp.principal.openid-identifier " +
132+ naked_account.openid_identifiers.any().identifier)
133+ store = IStore(GitRepository)
134+ # XXX cjwatson 2019-04-09: Expire macaroons after the number of
135+ # seconds given in the code.git.access_token_expiry feature flag,
136+ # defaulting to a week. This isn't very flexible, but for now it
137+ # saves on having to implement macaroon persistence in order that
138+ # users can revoke them.
139+ expiry_seconds_str = getFeatureFlag("code.git.access_token_expiry")
140+ if expiry_seconds_str is None:
141+ expiry_seconds = 60 * 60 * 24 * 7
142+ else:
143+ expiry_seconds = int(expiry_seconds_str)
144+ expiry = (
145+ get_transaction_timestamp(store) +
146+ timedelta(seconds=expiry_seconds))
147+ macaroon.add_first_party_caveat(
148+ "lp.expires " + expiry.strftime(self._timestamp_format))
149+ return macaroon
150+
151+ def checkVerificationContext(self, context, **kwargs):
152+ """See `MacaroonIssuerBase`.
153+
154+ For verification, the context is an `IGitRepository`.
155+ """
156+ if not IGitRepository.providedBy(context):
157+ raise ValueError("Cannot handle context %r." % context)
158+ return context
159+
160+ def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
161+ """See `MacaroonIssuerBase`."""
162+ if context is None:
163+ # We're only verifying that the macaroon could be valid for some
164+ # context.
165+ return True
166+ return caveat_value == str(context.id)
167+
168+ def verifyOpenIDIdentifier(self, caveat_value, context, **kwargs):
169+ """Verify an lp.principal.openid-identifier caveat."""
170+ user = kwargs.get("user")
171+ try:
172+ account = getUtility(IAccountSet).getByOpenIDIdentifier(
173+ caveat_value)
174+ except LookupError:
175+ return False
176+ return IPerson.providedBy(user) and user.account == account
177+
178+ def verifyExpires(self, caveat_value, context, **kwargs):
179+ """Verify an lp.expires caveat."""
180+ try:
181+ expires = datetime.strptime(
182+ caveat_value, self._timestamp_format).replace(tzinfo=pytz.UTC)
183+ except ValueError:
184+ return False
185+ store = IStore(GitRepository)
186+ return get_transaction_timestamp(store) < expires
187+
188+
189 def get_git_repository_privacy_filter(user, repository_class=GitRepository):
190 public_filter = repository_class.information_type.is_in(
191 PUBLIC_INFORMATION_TYPES)
192
193=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
194--- lib/lp/code/model/tests/test_gitrepository.py 2019-02-11 12:31:06 +0000
195+++ lib/lp/code/model/tests/test_gitrepository.py 2019-05-01 16:44:22 +0000
196@@ -17,12 +17,15 @@
197 import json
198
199 from bzrlib import urlutils
200+from fixtures import MockPatch
201 from lazr.lifecycle.event import ObjectModifiedEvent
202+from pymacaroons import Macaroon
203 import pytz
204 from sqlobject import SQLObjectNotFound
205 from storm.exceptions import LostObjectError
206 from storm.store import Store
207 from testtools.matchers import (
208+ AnyMatch,
209 EndsWith,
210 Equals,
211 Is,
212@@ -34,6 +37,7 @@
213 )
214 import transaction
215 from zope.component import getUtility
216+from zope.publisher.xmlrpc import TestRequest
217 from zope.security.interfaces import Unauthorized
218 from zope.security.proxy import removeSecurityProxy
219
220@@ -127,14 +131,22 @@
221 )
222 from lp.registry.interfaces.personproduct import IPersonProductFactory
223 from lp.registry.tests.test_accesspolicy import get_policies_for_artifact
224+from lp.services.authserver.xmlrpc import AuthServerAPIView
225 from lp.services.config import config
226 from lp.services.database.constants import UTC_NOW
227 from lp.services.database.interfaces import IStore
228 from lp.services.database.sqlbase import get_transaction_timestamp
229+from lp.services.features.testing import FeatureFixture
230 from lp.services.job.interfaces.job import JobStatus
231 from lp.services.job.model.job import Job
232 from lp.services.job.runner import JobRunner
233+from lp.services.macaroons.interfaces import IMacaroonIssuer
234+from lp.services.macaroons.testing import (
235+ find_caveats_by_name,
236+ MacaroonTestMixin,
237+ )
238 from lp.services.mail import stub
239+from lp.services.openid.model.openididentifier import OpenIdIdentifier
240 from lp.services.propertycache import clear_property_cache
241 from lp.services.utils import seconds_since_epoch
242 from lp.services.webapp.authorization import check_permission
243@@ -163,6 +175,8 @@
244 HasQueryCount,
245 )
246 from lp.testing.pages import webservice_for_person
247+from lp.xmlrpc import faults
248+from lp.xmlrpc.interfaces import IPrivateApplication
249
250
251 class TestGitRepository(TestCaseWithFactory):
252@@ -3894,3 +3908,222 @@
253 "refs/heads/next": Equals(["push", "force-push"]),
254 "refs/other": Equals([]),
255 }))
256+
257+
258+class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
259+ """Test GitRepository macaroon issuing and verification."""
260+
261+ layer = DatabaseFunctionalLayer
262+
263+ def setUp(self):
264+ super(TestGitRepositoryMacaroonIssuer, self).setUp()
265+ self.pushConfig(
266+ "launchpad", internal_macaroon_secret_key="some-secret")
267+
268+ def test_issueMacaroon_refuses_branch(self):
269+ branch = self.factory.makeAnyBranch()
270+ issuer = getUtility(IMacaroonIssuer, "git-repository")
271+ self.assertRaises(
272+ ValueError, removeSecurityProxy(issuer).issueMacaroon,
273+ branch, user=branch.owner)
274+
275+ def test_issueMacaroon_good(self):
276+ repository = self.factory.makeGitRepository()
277+ issuer = getUtility(IMacaroonIssuer, "git-repository")
278+ naked_account = removeSecurityProxy(repository.owner).account
279+ identifier = naked_account.openid_identifiers.any().identifier
280+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
281+ repository, user=repository.owner)
282+ now = get_transaction_timestamp(Store.of(repository))
283+ expires = now + timedelta(days=7)
284+ self.assertThat(macaroon, MatchesStructure(
285+ location=Equals(config.vhost.mainsite.hostname),
286+ identifier=Equals("git-repository"),
287+ caveats=MatchesListwise([
288+ MatchesStructure.byEquality(
289+ caveat_id="lp.git-repository %s" % repository.id),
290+ MatchesStructure.byEquality(
291+ caveat_id=(
292+ "lp.principal.openid-identifier %s" % identifier)),
293+ MatchesStructure.byEquality(
294+ caveat_id="lp.expires %s" % (
295+ expires.strftime("%Y-%m-%dT%H:%M:%S.%f"))),
296+ ])))
297+
298+ def test_issueMacaroon_expiry_feature_flag(self):
299+ self.useFixture(FeatureFixture(
300+ {"code.git.access_token_expiry": "3600"}))
301+ repository = self.factory.makeGitRepository()
302+ issuer = getUtility(IMacaroonIssuer, "git-repository")
303+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
304+ repository, user=repository.owner)
305+ now = get_transaction_timestamp(Store.of(repository))
306+ expires = now + timedelta(hours=1)
307+ self.assertThat(macaroon, MatchesStructure(
308+ caveats=AnyMatch(
309+ MatchesStructure.byEquality(
310+ caveat_id="lp.expires %s" % (
311+ expires.strftime("%Y-%m-%dT%H:%M:%S.%f"))))))
312+
313+ def test_issueMacaroon_no_user(self):
314+ repository = self.factory.makeGitRepository()
315+ issuer = getUtility(IMacaroonIssuer, "git-repository")
316+ self.assertRaises(
317+ Unauthorized,
318+ removeSecurityProxy(issuer).issueMacaroon, repository)
319+
320+ def test_issueMacaroon_not_via_authserver(self):
321+ repository = self.factory.makeGitRepository()
322+ private_root = getUtility(IPrivateApplication)
323+ authserver = AuthServerAPIView(private_root.authserver, TestRequest())
324+ self.assertEqual(
325+ faults.PermissionDenied(),
326+ authserver.issueMacaroon(
327+ "git-repository", "GitRepository", repository))
328+
329+ def test_verifyMacaroon_good(self):
330+ repository = self.factory.makeGitRepository()
331+ issuer = getUtility(IMacaroonIssuer, "git-repository")
332+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
333+ repository, user=repository.owner)
334+ self.assertMacaroonVerifies(
335+ issuer, macaroon, repository, user=repository.owner)
336+
337+ def test_verifyMacaroon_wrong_location(self):
338+ repository = self.factory.makeGitRepository()
339+ issuer = getUtility(IMacaroonIssuer, "git-repository")
340+ macaroon = Macaroon(
341+ location="another-location",
342+ key=removeSecurityProxy(issuer)._root_secret)
343+ self.assertMacaroonDoesNotVerify(
344+ ["Macaroon has unknown location 'another-location'."],
345+ issuer, macaroon, repository, user=repository.owner)
346+
347+ def test_verifyMacaroon_wrong_key(self):
348+ repository = self.factory.makeGitRepository()
349+ issuer = getUtility(IMacaroonIssuer, "git-repository")
350+ macaroon = Macaroon(
351+ location=config.vhost.mainsite.hostname, key="another-secret")
352+ self.assertMacaroonDoesNotVerify(
353+ ["Signatures do not match"],
354+ issuer, macaroon, repository, user=repository.owner)
355+
356+ def test_verifyMacaroon_wrong_repository(self):
357+ repository = self.factory.makeGitRepository()
358+ issuer = getUtility(IMacaroonIssuer, "git-repository")
359+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
360+ repository, user=repository.owner)
361+ self.assertMacaroonDoesNotVerify(
362+ ["Caveat check for 'lp.git-repository %s' failed." %
363+ repository.id],
364+ issuer, macaroon, self.factory.makeGitRepository(),
365+ user=repository.owner)
366+
367+ def test_verifyMacaroon_multiple_repository_caveats(self):
368+ repository = self.factory.makeGitRepository()
369+ issuer = getUtility(IMacaroonIssuer, "git-repository")
370+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
371+ repository, user=repository.owner)
372+ macaroon.add_first_party_caveat("lp.git-repository another")
373+ self.assertMacaroonDoesNotVerify(
374+ ["Multiple 'lp.git-repository' caveats are not allowed."],
375+ issuer, macaroon, repository, user=repository.owner)
376+
377+ def test_verifyMacaroon_wrong_user(self):
378+ repository = self.factory.makeGitRepository()
379+ issuer = getUtility(IMacaroonIssuer, "git-repository")
380+ naked_account = removeSecurityProxy(repository.owner).account
381+ identifier = naked_account.openid_identifiers.any().identifier
382+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
383+ repository, user=repository.owner)
384+ self.assertMacaroonDoesNotVerify(
385+ ["Caveat check for 'lp.principal.openid-identifier %s' failed." %
386+ identifier],
387+ issuer, macaroon, repository, user=self.factory.makePerson())
388+
389+ def test_verifyMacaroon_closed_account(self):
390+ # A closed account no longer has an OpenID identifier, so the
391+ # corresponding caveat doesn't match.
392+ repository = self.factory.makeGitRepository()
393+ owner = repository.owner
394+ issuer = getUtility(IMacaroonIssuer, "git-repository")
395+ naked_account = removeSecurityProxy(owner).account
396+ identifier = naked_account.openid_identifiers.any().identifier
397+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
398+ repository, user=owner)
399+ IStore(OpenIdIdentifier).find(
400+ OpenIdIdentifier,
401+ OpenIdIdentifier.account_id == owner.account.id).remove()
402+ self.assertMacaroonDoesNotVerify(
403+ ["Caveat check for 'lp.principal.openid-identifier %s' failed." %
404+ identifier],
405+ issuer, macaroon, repository, user=owner)
406+
407+ def test_verifyMacaroon_multiple_openid_identifier_caveats(self):
408+ repository = self.factory.makeGitRepository()
409+ issuer = getUtility(IMacaroonIssuer, "git-repository")
410+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
411+ repository, user=repository.owner)
412+ macaroon.add_first_party_caveat(
413+ "lp.principal.openid-identifier another")
414+ self.assertMacaroonDoesNotVerify(
415+ ["Multiple 'lp.principal.openid-identifier' caveats are not "
416+ "allowed."],
417+ issuer, macaroon, repository, user=repository.owner)
418+
419+ def test_verifyMacaroon_expired(self):
420+ repository = self.factory.makeGitRepository()
421+ issuer = getUtility(IMacaroonIssuer, "git-repository")
422+ macaroon = removeSecurityProxy(issuer).issueMacaroon(
423+ repository, user=repository.owner)
424+ now = get_transaction_timestamp(Store.of(repository))
425+ self.useFixture(MockPatch(
426+ "lp.code.model.gitrepository.get_transaction_timestamp",
427+ lambda _: now + timedelta(days=8)))
428+ self.assertMacaroonDoesNotVerify(
429+ ["Caveat check for '%s' failed." %
430+ find_caveats_by_name(macaroon, "lp.expires")[0].caveat_id],
431+ issuer, macaroon, repository, user=repository.owner)
432+
433+ def test_verifyMacaroon_multiple_expires_caveats(self):
434+ # If somebody attaches another expires caveat to the macaroon,
435+ # that's OK; we just take the strictest.
436+ repository = self.factory.makeGitRepository()
437+ issuer = getUtility(IMacaroonIssuer, "git-repository")
438+ macaroon1 = removeSecurityProxy(issuer).issueMacaroon(
439+ repository, user=repository.owner)
440+ macaroon2 = removeSecurityProxy(issuer).issueMacaroon(
441+ repository, user=repository.owner)
442+ now = get_transaction_timestamp(Store.of(repository))
443+ expires1 = now + timedelta(days=1)
444+ expires2 = now + timedelta(days=14)
445+ macaroon1.add_first_party_caveat(
446+ "lp.expires " + expires1.strftime("%Y-%m-%dT%H:%M:%S.%f"))
447+ macaroon2.add_first_party_caveat(
448+ "lp.expires " + expires2.strftime("%Y-%m-%dT%H:%M:%S.%f"))
449+ self.assertMacaroonVerifies(
450+ issuer, macaroon1, repository, user=repository.owner)
451+ self.assertMacaroonVerifies(
452+ issuer, macaroon2, repository, user=repository.owner)
453+ with MockPatch(
454+ "lp.code.model.gitrepository.get_transaction_timestamp",
455+ lambda _: now + timedelta(days=4)):
456+ self.assertMacaroonDoesNotVerify(
457+ ["Caveat check for '%s' failed." %
458+ find_caveats_by_name(macaroon1, "lp.expires")[1].caveat_id],
459+ issuer, macaroon1, repository, user=repository.owner)
460+ self.assertMacaroonVerifies(
461+ issuer, macaroon2, repository, user=repository.owner)
462+ with MockPatch(
463+ "lp.code.model.gitrepository.get_transaction_timestamp",
464+ lambda _: now + timedelta(days=8)):
465+ self.assertMacaroonDoesNotVerify(
466+ ["Caveat check for '%s' failed." %
467+ find_caveats_by_name(macaroon1, "lp.expires")[0].caveat_id,
468+ "Caveat check for '%s' failed." %
469+ find_caveats_by_name(macaroon1, "lp.expires")[1].caveat_id],
470+ issuer, macaroon1, repository, user=repository.owner)
471+ self.assertMacaroonDoesNotVerify(
472+ ["Caveat check for '%s' failed." %
473+ find_caveats_by_name(macaroon2, "lp.expires")[0].caveat_id],
474+ issuer, macaroon2, repository, user=repository.owner)
475
476=== modified file 'lib/lp/services/authserver/tests/test_authserver.py'
477--- lib/lp/services/authserver/tests/test_authserver.py 2019-04-25 09:55:35 +0000
478+++ lib/lp/services/authserver/tests/test_authserver.py 2019-05-01 16:44:22 +0000
479@@ -89,19 +89,19 @@
480 issuable_via_authserver = True
481 _root_secret = 'test'
482
483- def checkIssuingContext(self, context):
484+ def checkIssuingContext(self, context, **kwargs):
485 """See `MacaroonIssuerBase`."""
486 if not ILibraryFileAlias.providedBy(context):
487 raise BadMacaroonContext(context)
488 return context.id
489
490- def checkVerificationContext(self, context):
491+ def checkVerificationContext(self, context, **kwargs):
492 """See `IMacaroonIssuerBase`."""
493 if not ILibraryFileAlias.providedBy(context):
494 raise BadMacaroonContext(context)
495 return context
496
497- def verifyPrimaryCaveat(self, caveat_value, context):
498+ def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
499 """See `MacaroonIssuerBase`."""
500 return caveat_value == str(context.id)
501
502
503=== modified file 'lib/lp/services/macaroons/interfaces.py'
504--- lib/lp/services/macaroons/interfaces.py 2019-04-25 22:10:36 +0000
505+++ lib/lp/services/macaroons/interfaces.py 2019-05-01 16:44:22 +0000
506@@ -31,7 +31,8 @@
507 issuable_via_authserver = Bool(
508 "Does this issuer allow issuing macaroons via the authserver?")
509
510- def verifyMacaroon(macaroon, context, require_context=True, errors=None):
511+ def verifyMacaroon(macaroon, context, require_context=True, errors=None,
512+ **kwargs):
513 """Verify that `macaroon` is valid for `context`.
514
515 :param macaroon: A `Macaroon`.
516@@ -43,6 +44,8 @@
517 authentication/authorisation API.
518 :param errors: If non-None, any verification error messages will be
519 appended to this list.
520+ :param kwargs: Additional arguments that issuers may require to
521+ verify a macaroon.
522 :return: True if `macaroon` is valid for `context`, otherwise False.
523 """
524
525@@ -50,11 +53,13 @@
526 class IMacaroonIssuer(IMacaroonIssuerPublic):
527 """Interface to a policy for issuing and verifying macaroons."""
528
529- def issueMacaroon(context):
530+ def issueMacaroon(context, **kwargs):
531 """Issue a macaroon for `context`.
532
533 :param context: The context that the returned macaroon should relate
534 to.
535+ :param kwargs: Additional arguments that issuers may require to
536+ issue a macaroon.
537 :raises BadMacaroonContext: if the context is unsuitable.
538 :return: A macaroon.
539 """
540
541=== modified file 'lib/lp/services/macaroons/model.py'
542--- lib/lp/services/macaroons/model.py 2019-04-25 22:10:36 +0000
543+++ lib/lp/services/macaroons/model.py 2019-05-01 16:44:22 +0000
544@@ -25,6 +25,31 @@
545
546 issuable_via_authserver = False
547
548+ # A mapping of caveat names to "checker" callables that verify the
549+ # corresponding caveat text. The signature of each checker is
550+ # (caveat_value, context, **kwargs) -> bool, where caveat_value is the
551+ # text of the caveat with the caveat name removed, context is the
552+ # issuer-specific context to check, and kwargs is any other keyword
553+ # arguments that were given to verifyMacaroon; it should return True if
554+ # the caveat is allowed, otherwise False.
555+ #
556+ # The context passed in may be None, in which case the checker may
557+ # choose to only verify that the caveat could be valid for some context,
558+ # or may simply return False if this is unsupported. This is useful for
559+ # issuers that support APIs with separate authentication and
560+ # authorisation phases.
561+ #
562+ # The "primary context caveat" added to all macaroons issued by this
563+ # base class does not need to be listed here; it is handled by the
564+ # verifyContextCaveat method.
565+ checkers = {}
566+
567+ # Caveat names in this set may appear more than once (in which case they
568+ # have the usual subtractive semantics, so the union of all the
569+ # constraints they express applies). Any other caveats may only appear
570+ # once.
571+ allow_multiple = set()
572+
573 @property
574 def identifier(self):
575 """An identifying name for this issuer."""
576@@ -43,7 +68,7 @@
577 "launchpad.internal_macaroon_secret_key not configured.")
578 return secret
579
580- def checkIssuingContext(self, context):
581+ def checkIssuingContext(self, context, **kwargs):
582 """Check that the issuing context is suitable.
583
584 Concrete implementations may implement this method to check that the
585@@ -52,18 +77,16 @@
586 was passed in or an adapted one.
587
588 :param context: The context to check.
589+ :param kwargs: Additional arguments that issuers may require to
590+ issue a macaroon.
591 :raises BadMacaroonContext: if the context is unsuitable.
592 :return: The context to use to create the primary caveat.
593 """
594 return context
595
596- def issueMacaroon(self, context):
597- """See `IMacaroonIssuer`.
598-
599- Concrete implementations should normally wrap this with some
600- additional checks of and/or changes to the context.
601- """
602- context = self.checkIssuingContext(context)
603+ def issueMacaroon(self, context, **kwargs):
604+ """See `IMacaroonIssuer`."""
605+ context = self.checkIssuingContext(context, **kwargs)
606 macaroon = Macaroon(
607 location=config.vhost.mainsite.hostname,
608 identifier=self.identifier, key=self._root_secret)
609@@ -71,7 +94,7 @@
610 "%s %s" % (self._primary_caveat_name, context))
611 return macaroon
612
613- def checkVerificationContext(self, context):
614+ def checkVerificationContext(self, context, **kwargs):
615 """Check that the verification context is suitable.
616
617 Concrete implementations may implement this method to check that the
618@@ -80,23 +103,27 @@
619 context that was passed in or an adapted one.
620
621 :param context: The context to check.
622+ :param kwargs: Additional arguments that issuers may require to
623+ verify a macaroon.
624 :raises BadMacaroonContext: if the context is unsuitable.
625 :return: The context to pass to individual caveat checkers.
626 """
627 return context
628
629- def verifyPrimaryCaveat(self, caveat_value, context):
630+ def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
631 """Verify the primary context caveat on one of this issuer's macaroons.
632
633- :param caveat_value: The text of the caveat, with this issuer's
634- prefix removed.
635+ :param caveat_value: The text of the caveat with the caveat name
636+ removed.
637 :param context: The context to check.
638+ :param kwargs: Additional arguments that issuers may require to
639+ verify a macaroon.
640 :return: True if this caveat is allowed, otherwise False.
641 """
642 raise NotImplementedError
643
644 def verifyMacaroon(self, macaroon, context, require_context=True,
645- errors=None):
646+ errors=None, **kwargs):
647 """See `IMacaroonIssuer`."""
648 if macaroon.location != config.vhost.mainsite.hostname:
649 if errors is not None:
650@@ -115,6 +142,7 @@
651 if errors is not None:
652 errors.append(str(e))
653 return False
654+ seen = set()
655
656 def verify(caveat):
657 try:
658@@ -123,16 +151,22 @@
659 if errors is not None:
660 errors.append("Cannot parse caveat '%s'." % caveat)
661 return False
662+ if caveat_name not in self.allow_multiple and caveat_name in seen:
663+ if errors is not None:
664+ errors.append(
665+ "Multiple '%s' caveats are not allowed." % caveat_name)
666+ return False
667+ seen.add(caveat_name)
668 if caveat_name == self._primary_caveat_name:
669 checker = self.verifyPrimaryCaveat
670 else:
671- # XXX cjwatson 2019-04-09: For now we just fail closed if
672- # there are any other caveats, which is good enough for
673- # internal use.
674- if errors is not None:
675- errors.append("Unhandled caveat name '%s'." % caveat_name)
676- return False
677- if not checker(caveat_value, context):
678+ checker = self.checkers.get(caveat_name)
679+ if checker is None:
680+ if errors is not None:
681+ errors.append(
682+ "Unhandled caveat name '%s'." % caveat_name)
683+ return False
684+ if not checker(caveat_value, context, **kwargs):
685 if errors is not None:
686 errors.append("Caveat check for '%s' failed." % caveat)
687 return False
688
689=== modified file 'lib/lp/snappy/model/snapbuild.py'
690--- lib/lp/snappy/model/snapbuild.py 2019-04-25 09:34:40 +0000
691+++ lib/lp/snappy/model/snapbuild.py 2019-05-01 16:44:22 +0000
692@@ -599,7 +599,7 @@
693 identifier = "snap-build"
694 issuable_via_authserver = True
695
696- def checkIssuingContext(self, context):
697+ def checkIssuingContext(self, context, **kwargs):
698 """See `MacaroonIssuerBase`.
699
700 For issuing, the context is an `ISnapBuild`.
701@@ -611,13 +611,13 @@
702 context, "Refusing to issue macaroon for public build.")
703 return removeSecurityProxy(context).id
704
705- def checkVerificationContext(self, context):
706+ def checkVerificationContext(self, context, **kwargs):
707 """See `MacaroonIssuerBase`."""
708 if not IGitRepository.providedBy(context):
709 raise BadMacaroonContext(context)
710 return context
711
712- def verifyPrimaryCaveat(self, caveat_value, context):
713+ def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
714 """See `MacaroonIssuerBase`.
715
716 For verification, the context is an `IGitRepository`. We check that
717
718=== modified file 'lib/lp/soyuz/model/binarypackagebuild.py'
719--- lib/lp/soyuz/model/binarypackagebuild.py 2019-04-24 12:50:20 +0000
720+++ lib/lp/soyuz/model/binarypackagebuild.py 2019-05-01 16:44:22 +0000
721@@ -1387,7 +1387,7 @@
722 # the named build directly.
723 return "lp.principal.binary-package-build"
724
725- def checkIssuingContext(self, context):
726+ def checkIssuingContext(self, context, **kwargs):
727 """See `MacaroonIssuerBase`.
728
729 For issuing, the context is an `IBinaryPackageBuild`.
730@@ -1397,13 +1397,13 @@
731 context, "Refusing to issue macaroon for public build.")
732 return removeSecurityProxy(context).id
733
734- def checkVerificationContext(self, context):
735+ def checkVerificationContext(self, context, **kwargs):
736 """See `MacaroonIssuerBase`."""
737 if not ILibraryFileAlias.providedBy(context):
738 raise BadMacaroonContext(context)
739 return context
740
741- def verifyPrimaryCaveat(self, caveat_value, context):
742+ def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
743 """See `MacaroonIssuerBase`.
744
745 For verification, the context is an `ILibraryFileAlias`. We check