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
=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml 2018-10-16 15:52:00 +0000
+++ lib/lp/code/configure.zcml 2019-05-01 16:44:22 +0000
@@ -1,4 +1,4 @@
1<!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the1<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
@@ -856,6 +856,15 @@
856 <allow interface="lp.code.interfaces.gitrepository.IGitRepositorySet" />856 <allow interface="lp.code.interfaces.gitrepository.IGitRepositorySet" />
857 </securedutility>857 </securedutility>
858858
859 <!-- GitRepositoryMacaroonIssuer -->
860
861 <securedutility
862 class="lp.code.model.gitrepository.GitRepositoryMacaroonIssuer"
863 provides="lp.services.macaroons.interfaces.IMacaroonIssuer"
864 name="git-repository">
865 <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic" />
866 </securedutility>
867
859 <!-- GitRepositoryDelta -->868 <!-- GitRepositoryDelta -->
860869
861 <class class="lp.code.adapters.gitrepository.GitRepositoryDelta">870 <class class="lp.code.adapters.gitrepository.GitRepositoryDelta">
862871
=== modified file 'lib/lp/code/model/codeimportjob.py'
--- lib/lp/code/model/codeimportjob.py 2019-04-26 13:13:37 +0000
+++ lib/lp/code/model/codeimportjob.py 2019-05-01 16:44:22 +0000
@@ -428,14 +428,14 @@
428 "launchpad.internal_macaroon_secret_key not configured.")428 "launchpad.internal_macaroon_secret_key not configured.")
429 return secret429 return secret
430430
431 def checkIssuingContext(self, context):431 def checkIssuingContext(self, context, **kwargs):
432 """See `MacaroonIssuerBase`."""432 """See `MacaroonIssuerBase`."""
433 if context.code_import.git_repository is None:433 if context.code_import.git_repository is None:
434 raise BadMacaroonContext(434 raise BadMacaroonContext(
435 context, "context.code_import.git_repository is None")435 context, "context.code_import.git_repository is None")
436 return context.id436 return context.id
437437
438 def checkVerificationContext(self, context):438 def checkVerificationContext(self, context, **kwargs):
439 """See `MacaroonIssuerBase`.439 """See `MacaroonIssuerBase`.
440440
441 For verification, the context may be an `ICodeImportJob`, in which441 For verification, the context may be an `ICodeImportJob`, in which
@@ -461,7 +461,7 @@
461 context, "%r is not in the RUNNING state." % context)461 context, "%r is not in the RUNNING state." % context)
462 return context462 return context
463463
464 def verifyPrimaryCaveat(self, caveat_value, context):464 def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
465 """See `MacaroonIssuerBase`."""465 """See `MacaroonIssuerBase`."""
466 if context is None:466 if context is None:
467 # We're only verifying that the macaroon could be valid for some467 # We're only verifying that the macaroon could be valid for some
468468
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2019-04-02 08:12:27 +0000
+++ lib/lp/code/model/gitrepository.py 2019-05-01 16:44:22 +0000
@@ -13,7 +13,10 @@
13 defaultdict,13 defaultdict,
14 OrderedDict,14 OrderedDict,
15 )15 )
16from datetime import datetime16from datetime import (
17 datetime,
18 timedelta,
19 )
17import email20import email
18from fnmatch import fnmatch21from fnmatch import fnmatch
19from functools import partial22from functools import partial
@@ -174,6 +177,7 @@
174from lp.services.database.decoratedresultset import DecoratedResultSet177from lp.services.database.decoratedresultset import DecoratedResultSet
175from lp.services.database.enumcol import EnumCol178from lp.services.database.enumcol import EnumCol
176from lp.services.database.interfaces import IStore179from lp.services.database.interfaces import IStore
180from lp.services.database.sqlbase import get_transaction_timestamp
177from lp.services.database.stormbase import StormBase181from lp.services.database.stormbase import StormBase
178from lp.services.database.stormexpr import (182from lp.services.database.stormexpr import (
179 Array,183 Array,
@@ -182,8 +186,12 @@
182 BulkUpdate,186 BulkUpdate,
183 Values,187 Values,
184 )188 )
189from lp.services.features import getFeatureFlag
190from lp.services.identity.interfaces.account import IAccountSet
185from lp.services.job.interfaces.job import JobStatus191from lp.services.job.interfaces.job import JobStatus
186from lp.services.job.model.job import Job192from lp.services.job.model.job import Job
193from lp.services.macaroons.interfaces import IMacaroonIssuer
194from lp.services.macaroons.model import MacaroonIssuerBase
187from lp.services.mail.notificationrecipientset import NotificationRecipientSet195from lp.services.mail.notificationrecipientset import NotificationRecipientSet
188from lp.services.propertycache import (196from lp.services.propertycache import (
189 cachedproperty,197 cachedproperty,
@@ -1764,6 +1772,98 @@
1764 repository.project_id: repository for repository in repositories}1772 repository.project_id: repository for repository in repositories}
17651773
17661774
1775@implementer(IMacaroonIssuer)
1776class GitRepositoryMacaroonIssuer(MacaroonIssuerBase):
1777
1778 identifier = "git-repository"
1779 allow_multiple = {"lp.expires"}
1780
1781 _timestamp_format = "%Y-%m-%dT%H:%M:%S.%f"
1782
1783 def __init__(self):
1784 super(GitRepositoryMacaroonIssuer, self).__init__()
1785 self.checkers = {
1786 "lp.principal.openid-identifier": self.verifyOpenIDIdentifier,
1787 "lp.expires": self.verifyExpires,
1788 }
1789
1790 def checkIssuingContext(self, context, user=None, **kwargs):
1791 """See `MacaroonIssuerBase`.
1792
1793 For issuing, the context is an `IGitRepository`.
1794 """
1795 if user is None:
1796 raise Unauthorized(
1797 "git-repository macaroons may only be issued for a logged-in "
1798 "user.")
1799 if not IGitRepository.providedBy(context):
1800 raise ValueError("Cannot handle context %r." % context)
1801 return context.id
1802
1803 def issueMacaroon(self, context, user=None, **kwargs):
1804 """See `IMacaroonIssuer`."""
1805 macaroon = super(GitRepositoryMacaroonIssuer, self).issueMacaroon(
1806 context, user=user, **kwargs)
1807 naked_account = removeSecurityProxy(user).account
1808 macaroon.add_first_party_caveat(
1809 "lp.principal.openid-identifier " +
1810 naked_account.openid_identifiers.any().identifier)
1811 store = IStore(GitRepository)
1812 # XXX cjwatson 2019-04-09: Expire macaroons after the number of
1813 # seconds given in the code.git.access_token_expiry feature flag,
1814 # defaulting to a week. This isn't very flexible, but for now it
1815 # saves on having to implement macaroon persistence in order that
1816 # users can revoke them.
1817 expiry_seconds_str = getFeatureFlag("code.git.access_token_expiry")
1818 if expiry_seconds_str is None:
1819 expiry_seconds = 60 * 60 * 24 * 7
1820 else:
1821 expiry_seconds = int(expiry_seconds_str)
1822 expiry = (
1823 get_transaction_timestamp(store) +
1824 timedelta(seconds=expiry_seconds))
1825 macaroon.add_first_party_caveat(
1826 "lp.expires " + expiry.strftime(self._timestamp_format))
1827 return macaroon
1828
1829 def checkVerificationContext(self, context, **kwargs):
1830 """See `MacaroonIssuerBase`.
1831
1832 For verification, the context is an `IGitRepository`.
1833 """
1834 if not IGitRepository.providedBy(context):
1835 raise ValueError("Cannot handle context %r." % context)
1836 return context
1837
1838 def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
1839 """See `MacaroonIssuerBase`."""
1840 if context is None:
1841 # We're only verifying that the macaroon could be valid for some
1842 # context.
1843 return True
1844 return caveat_value == str(context.id)
1845
1846 def verifyOpenIDIdentifier(self, caveat_value, context, **kwargs):
1847 """Verify an lp.principal.openid-identifier caveat."""
1848 user = kwargs.get("user")
1849 try:
1850 account = getUtility(IAccountSet).getByOpenIDIdentifier(
1851 caveat_value)
1852 except LookupError:
1853 return False
1854 return IPerson.providedBy(user) and user.account == account
1855
1856 def verifyExpires(self, caveat_value, context, **kwargs):
1857 """Verify an lp.expires caveat."""
1858 try:
1859 expires = datetime.strptime(
1860 caveat_value, self._timestamp_format).replace(tzinfo=pytz.UTC)
1861 except ValueError:
1862 return False
1863 store = IStore(GitRepository)
1864 return get_transaction_timestamp(store) < expires
1865
1866
1767def get_git_repository_privacy_filter(user, repository_class=GitRepository):1867def get_git_repository_privacy_filter(user, repository_class=GitRepository):
1768 public_filter = repository_class.information_type.is_in(1868 public_filter = repository_class.information_type.is_in(
1769 PUBLIC_INFORMATION_TYPES)1869 PUBLIC_INFORMATION_TYPES)
17701870
=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py 2019-02-11 12:31:06 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py 2019-05-01 16:44:22 +0000
@@ -17,12 +17,15 @@
17import json17import json
1818
19from bzrlib import urlutils19from bzrlib import urlutils
20from fixtures import MockPatch
20from lazr.lifecycle.event import ObjectModifiedEvent21from lazr.lifecycle.event import ObjectModifiedEvent
22from pymacaroons import Macaroon
21import pytz23import pytz
22from sqlobject import SQLObjectNotFound24from sqlobject import SQLObjectNotFound
23from storm.exceptions import LostObjectError25from storm.exceptions import LostObjectError
24from storm.store import Store26from storm.store import Store
25from testtools.matchers import (27from testtools.matchers import (
28 AnyMatch,
26 EndsWith,29 EndsWith,
27 Equals,30 Equals,
28 Is,31 Is,
@@ -34,6 +37,7 @@
34 )37 )
35import transaction38import transaction
36from zope.component import getUtility39from zope.component import getUtility
40from zope.publisher.xmlrpc import TestRequest
37from zope.security.interfaces import Unauthorized41from zope.security.interfaces import Unauthorized
38from zope.security.proxy import removeSecurityProxy42from zope.security.proxy import removeSecurityProxy
3943
@@ -127,14 +131,22 @@
127 )131 )
128from lp.registry.interfaces.personproduct import IPersonProductFactory132from lp.registry.interfaces.personproduct import IPersonProductFactory
129from lp.registry.tests.test_accesspolicy import get_policies_for_artifact133from lp.registry.tests.test_accesspolicy import get_policies_for_artifact
134from lp.services.authserver.xmlrpc import AuthServerAPIView
130from lp.services.config import config135from lp.services.config import config
131from lp.services.database.constants import UTC_NOW136from lp.services.database.constants import UTC_NOW
132from lp.services.database.interfaces import IStore137from lp.services.database.interfaces import IStore
133from lp.services.database.sqlbase import get_transaction_timestamp138from lp.services.database.sqlbase import get_transaction_timestamp
139from lp.services.features.testing import FeatureFixture
134from lp.services.job.interfaces.job import JobStatus140from lp.services.job.interfaces.job import JobStatus
135from lp.services.job.model.job import Job141from lp.services.job.model.job import Job
136from lp.services.job.runner import JobRunner142from lp.services.job.runner import JobRunner
143from lp.services.macaroons.interfaces import IMacaroonIssuer
144from lp.services.macaroons.testing import (
145 find_caveats_by_name,
146 MacaroonTestMixin,
147 )
137from lp.services.mail import stub148from lp.services.mail import stub
149from lp.services.openid.model.openididentifier import OpenIdIdentifier
138from lp.services.propertycache import clear_property_cache150from lp.services.propertycache import clear_property_cache
139from lp.services.utils import seconds_since_epoch151from lp.services.utils import seconds_since_epoch
140from lp.services.webapp.authorization import check_permission152from lp.services.webapp.authorization import check_permission
@@ -163,6 +175,8 @@
163 HasQueryCount,175 HasQueryCount,
164 )176 )
165from lp.testing.pages import webservice_for_person177from lp.testing.pages import webservice_for_person
178from lp.xmlrpc import faults
179from lp.xmlrpc.interfaces import IPrivateApplication
166180
167181
168class TestGitRepository(TestCaseWithFactory):182class TestGitRepository(TestCaseWithFactory):
@@ -3894,3 +3908,222 @@
3894 "refs/heads/next": Equals(["push", "force-push"]),3908 "refs/heads/next": Equals(["push", "force-push"]),
3895 "refs/other": Equals([]),3909 "refs/other": Equals([]),
3896 }))3910 }))
3911
3912
3913class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
3914 """Test GitRepository macaroon issuing and verification."""
3915
3916 layer = DatabaseFunctionalLayer
3917
3918 def setUp(self):
3919 super(TestGitRepositoryMacaroonIssuer, self).setUp()
3920 self.pushConfig(
3921 "launchpad", internal_macaroon_secret_key="some-secret")
3922
3923 def test_issueMacaroon_refuses_branch(self):
3924 branch = self.factory.makeAnyBranch()
3925 issuer = getUtility(IMacaroonIssuer, "git-repository")
3926 self.assertRaises(
3927 ValueError, removeSecurityProxy(issuer).issueMacaroon,
3928 branch, user=branch.owner)
3929
3930 def test_issueMacaroon_good(self):
3931 repository = self.factory.makeGitRepository()
3932 issuer = getUtility(IMacaroonIssuer, "git-repository")
3933 naked_account = removeSecurityProxy(repository.owner).account
3934 identifier = naked_account.openid_identifiers.any().identifier
3935 macaroon = removeSecurityProxy(issuer).issueMacaroon(
3936 repository, user=repository.owner)
3937 now = get_transaction_timestamp(Store.of(repository))
3938 expires = now + timedelta(days=7)
3939 self.assertThat(macaroon, MatchesStructure(
3940 location=Equals(config.vhost.mainsite.hostname),
3941 identifier=Equals("git-repository"),
3942 caveats=MatchesListwise([
3943 MatchesStructure.byEquality(
3944 caveat_id="lp.git-repository %s" % repository.id),
3945 MatchesStructure.byEquality(
3946 caveat_id=(
3947 "lp.principal.openid-identifier %s" % identifier)),
3948 MatchesStructure.byEquality(
3949 caveat_id="lp.expires %s" % (
3950 expires.strftime("%Y-%m-%dT%H:%M:%S.%f"))),
3951 ])))
3952
3953 def test_issueMacaroon_expiry_feature_flag(self):
3954 self.useFixture(FeatureFixture(
3955 {"code.git.access_token_expiry": "3600"}))
3956 repository = self.factory.makeGitRepository()
3957 issuer = getUtility(IMacaroonIssuer, "git-repository")
3958 macaroon = removeSecurityProxy(issuer).issueMacaroon(
3959 repository, user=repository.owner)
3960 now = get_transaction_timestamp(Store.of(repository))
3961 expires = now + timedelta(hours=1)
3962 self.assertThat(macaroon, MatchesStructure(
3963 caveats=AnyMatch(
3964 MatchesStructure.byEquality(
3965 caveat_id="lp.expires %s" % (
3966 expires.strftime("%Y-%m-%dT%H:%M:%S.%f"))))))
3967
3968 def test_issueMacaroon_no_user(self):
3969 repository = self.factory.makeGitRepository()
3970 issuer = getUtility(IMacaroonIssuer, "git-repository")
3971 self.assertRaises(
3972 Unauthorized,
3973 removeSecurityProxy(issuer).issueMacaroon, repository)
3974
3975 def test_issueMacaroon_not_via_authserver(self):
3976 repository = self.factory.makeGitRepository()
3977 private_root = getUtility(IPrivateApplication)
3978 authserver = AuthServerAPIView(private_root.authserver, TestRequest())
3979 self.assertEqual(
3980 faults.PermissionDenied(),
3981 authserver.issueMacaroon(
3982 "git-repository", "GitRepository", repository))
3983
3984 def test_verifyMacaroon_good(self):
3985 repository = self.factory.makeGitRepository()
3986 issuer = getUtility(IMacaroonIssuer, "git-repository")
3987 macaroon = removeSecurityProxy(issuer).issueMacaroon(
3988 repository, user=repository.owner)
3989 self.assertMacaroonVerifies(
3990 issuer, macaroon, repository, user=repository.owner)
3991
3992 def test_verifyMacaroon_wrong_location(self):
3993 repository = self.factory.makeGitRepository()
3994 issuer = getUtility(IMacaroonIssuer, "git-repository")
3995 macaroon = Macaroon(
3996 location="another-location",
3997 key=removeSecurityProxy(issuer)._root_secret)
3998 self.assertMacaroonDoesNotVerify(
3999 ["Macaroon has unknown location 'another-location'."],
4000 issuer, macaroon, repository, user=repository.owner)
4001
4002 def test_verifyMacaroon_wrong_key(self):
4003 repository = self.factory.makeGitRepository()
4004 issuer = getUtility(IMacaroonIssuer, "git-repository")
4005 macaroon = Macaroon(
4006 location=config.vhost.mainsite.hostname, key="another-secret")
4007 self.assertMacaroonDoesNotVerify(
4008 ["Signatures do not match"],
4009 issuer, macaroon, repository, user=repository.owner)
4010
4011 def test_verifyMacaroon_wrong_repository(self):
4012 repository = self.factory.makeGitRepository()
4013 issuer = getUtility(IMacaroonIssuer, "git-repository")
4014 macaroon = removeSecurityProxy(issuer).issueMacaroon(
4015 repository, user=repository.owner)
4016 self.assertMacaroonDoesNotVerify(
4017 ["Caveat check for 'lp.git-repository %s' failed." %
4018 repository.id],
4019 issuer, macaroon, self.factory.makeGitRepository(),
4020 user=repository.owner)
4021
4022 def test_verifyMacaroon_multiple_repository_caveats(self):
4023 repository = self.factory.makeGitRepository()
4024 issuer = getUtility(IMacaroonIssuer, "git-repository")
4025 macaroon = removeSecurityProxy(issuer).issueMacaroon(
4026 repository, user=repository.owner)
4027 macaroon.add_first_party_caveat("lp.git-repository another")
4028 self.assertMacaroonDoesNotVerify(
4029 ["Multiple 'lp.git-repository' caveats are not allowed."],
4030 issuer, macaroon, repository, user=repository.owner)
4031
4032 def test_verifyMacaroon_wrong_user(self):
4033 repository = self.factory.makeGitRepository()
4034 issuer = getUtility(IMacaroonIssuer, "git-repository")
4035 naked_account = removeSecurityProxy(repository.owner).account
4036 identifier = naked_account.openid_identifiers.any().identifier
4037 macaroon = removeSecurityProxy(issuer).issueMacaroon(
4038 repository, user=repository.owner)
4039 self.assertMacaroonDoesNotVerify(
4040 ["Caveat check for 'lp.principal.openid-identifier %s' failed." %
4041 identifier],
4042 issuer, macaroon, repository, user=self.factory.makePerson())
4043
4044 def test_verifyMacaroon_closed_account(self):
4045 # A closed account no longer has an OpenID identifier, so the
4046 # corresponding caveat doesn't match.
4047 repository = self.factory.makeGitRepository()
4048 owner = repository.owner
4049 issuer = getUtility(IMacaroonIssuer, "git-repository")
4050 naked_account = removeSecurityProxy(owner).account
4051 identifier = naked_account.openid_identifiers.any().identifier
4052 macaroon = removeSecurityProxy(issuer).issueMacaroon(
4053 repository, user=owner)
4054 IStore(OpenIdIdentifier).find(
4055 OpenIdIdentifier,
4056 OpenIdIdentifier.account_id == owner.account.id).remove()
4057 self.assertMacaroonDoesNotVerify(
4058 ["Caveat check for 'lp.principal.openid-identifier %s' failed." %
4059 identifier],
4060 issuer, macaroon, repository, user=owner)
4061
4062 def test_verifyMacaroon_multiple_openid_identifier_caveats(self):
4063 repository = self.factory.makeGitRepository()
4064 issuer = getUtility(IMacaroonIssuer, "git-repository")
4065 macaroon = removeSecurityProxy(issuer).issueMacaroon(
4066 repository, user=repository.owner)
4067 macaroon.add_first_party_caveat(
4068 "lp.principal.openid-identifier another")
4069 self.assertMacaroonDoesNotVerify(
4070 ["Multiple 'lp.principal.openid-identifier' caveats are not "
4071 "allowed."],
4072 issuer, macaroon, repository, user=repository.owner)
4073
4074 def test_verifyMacaroon_expired(self):
4075 repository = self.factory.makeGitRepository()
4076 issuer = getUtility(IMacaroonIssuer, "git-repository")
4077 macaroon = removeSecurityProxy(issuer).issueMacaroon(
4078 repository, user=repository.owner)
4079 now = get_transaction_timestamp(Store.of(repository))
4080 self.useFixture(MockPatch(
4081 "lp.code.model.gitrepository.get_transaction_timestamp",
4082 lambda _: now + timedelta(days=8)))
4083 self.assertMacaroonDoesNotVerify(
4084 ["Caveat check for '%s' failed." %
4085 find_caveats_by_name(macaroon, "lp.expires")[0].caveat_id],
4086 issuer, macaroon, repository, user=repository.owner)
4087
4088 def test_verifyMacaroon_multiple_expires_caveats(self):
4089 # If somebody attaches another expires caveat to the macaroon,
4090 # that's OK; we just take the strictest.
4091 repository = self.factory.makeGitRepository()
4092 issuer = getUtility(IMacaroonIssuer, "git-repository")
4093 macaroon1 = removeSecurityProxy(issuer).issueMacaroon(
4094 repository, user=repository.owner)
4095 macaroon2 = removeSecurityProxy(issuer).issueMacaroon(
4096 repository, user=repository.owner)
4097 now = get_transaction_timestamp(Store.of(repository))
4098 expires1 = now + timedelta(days=1)
4099 expires2 = now + timedelta(days=14)
4100 macaroon1.add_first_party_caveat(
4101 "lp.expires " + expires1.strftime("%Y-%m-%dT%H:%M:%S.%f"))
4102 macaroon2.add_first_party_caveat(
4103 "lp.expires " + expires2.strftime("%Y-%m-%dT%H:%M:%S.%f"))
4104 self.assertMacaroonVerifies(
4105 issuer, macaroon1, repository, user=repository.owner)
4106 self.assertMacaroonVerifies(
4107 issuer, macaroon2, repository, user=repository.owner)
4108 with MockPatch(
4109 "lp.code.model.gitrepository.get_transaction_timestamp",
4110 lambda _: now + timedelta(days=4)):
4111 self.assertMacaroonDoesNotVerify(
4112 ["Caveat check for '%s' failed." %
4113 find_caveats_by_name(macaroon1, "lp.expires")[1].caveat_id],
4114 issuer, macaroon1, repository, user=repository.owner)
4115 self.assertMacaroonVerifies(
4116 issuer, macaroon2, repository, user=repository.owner)
4117 with MockPatch(
4118 "lp.code.model.gitrepository.get_transaction_timestamp",
4119 lambda _: now + timedelta(days=8)):
4120 self.assertMacaroonDoesNotVerify(
4121 ["Caveat check for '%s' failed." %
4122 find_caveats_by_name(macaroon1, "lp.expires")[0].caveat_id,
4123 "Caveat check for '%s' failed." %
4124 find_caveats_by_name(macaroon1, "lp.expires")[1].caveat_id],
4125 issuer, macaroon1, repository, user=repository.owner)
4126 self.assertMacaroonDoesNotVerify(
4127 ["Caveat check for '%s' failed." %
4128 find_caveats_by_name(macaroon2, "lp.expires")[0].caveat_id],
4129 issuer, macaroon2, repository, user=repository.owner)
38974130
=== modified file 'lib/lp/services/authserver/tests/test_authserver.py'
--- lib/lp/services/authserver/tests/test_authserver.py 2019-04-25 09:55:35 +0000
+++ lib/lp/services/authserver/tests/test_authserver.py 2019-05-01 16:44:22 +0000
@@ -89,19 +89,19 @@
89 issuable_via_authserver = True89 issuable_via_authserver = True
90 _root_secret = 'test'90 _root_secret = 'test'
9191
92 def checkIssuingContext(self, context):92 def checkIssuingContext(self, context, **kwargs):
93 """See `MacaroonIssuerBase`."""93 """See `MacaroonIssuerBase`."""
94 if not ILibraryFileAlias.providedBy(context):94 if not ILibraryFileAlias.providedBy(context):
95 raise BadMacaroonContext(context)95 raise BadMacaroonContext(context)
96 return context.id96 return context.id
9797
98 def checkVerificationContext(self, context):98 def checkVerificationContext(self, context, **kwargs):
99 """See `IMacaroonIssuerBase`."""99 """See `IMacaroonIssuerBase`."""
100 if not ILibraryFileAlias.providedBy(context):100 if not ILibraryFileAlias.providedBy(context):
101 raise BadMacaroonContext(context)101 raise BadMacaroonContext(context)
102 return context102 return context
103103
104 def verifyPrimaryCaveat(self, caveat_value, context):104 def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
105 """See `MacaroonIssuerBase`."""105 """See `MacaroonIssuerBase`."""
106 return caveat_value == str(context.id)106 return caveat_value == str(context.id)
107107
108108
=== modified file 'lib/lp/services/macaroons/interfaces.py'
--- lib/lp/services/macaroons/interfaces.py 2019-04-25 22:10:36 +0000
+++ lib/lp/services/macaroons/interfaces.py 2019-05-01 16:44:22 +0000
@@ -31,7 +31,8 @@
31 issuable_via_authserver = Bool(31 issuable_via_authserver = Bool(
32 "Does this issuer allow issuing macaroons via the authserver?")32 "Does this issuer allow issuing macaroons via the authserver?")
3333
34 def verifyMacaroon(macaroon, context, require_context=True, errors=None):34 def verifyMacaroon(macaroon, context, require_context=True, errors=None,
35 **kwargs):
35 """Verify that `macaroon` is valid for `context`.36 """Verify that `macaroon` is valid for `context`.
3637
37 :param macaroon: A `Macaroon`.38 :param macaroon: A `Macaroon`.
@@ -43,6 +44,8 @@
43 authentication/authorisation API.44 authentication/authorisation API.
44 :param errors: If non-None, any verification error messages will be45 :param errors: If non-None, any verification error messages will be
45 appended to this list.46 appended to this list.
47 :param kwargs: Additional arguments that issuers may require to
48 verify a macaroon.
46 :return: True if `macaroon` is valid for `context`, otherwise False.49 :return: True if `macaroon` is valid for `context`, otherwise False.
47 """50 """
4851
@@ -50,11 +53,13 @@
50class IMacaroonIssuer(IMacaroonIssuerPublic):53class IMacaroonIssuer(IMacaroonIssuerPublic):
51 """Interface to a policy for issuing and verifying macaroons."""54 """Interface to a policy for issuing and verifying macaroons."""
5255
53 def issueMacaroon(context):56 def issueMacaroon(context, **kwargs):
54 """Issue a macaroon for `context`.57 """Issue a macaroon for `context`.
5558
56 :param context: The context that the returned macaroon should relate59 :param context: The context that the returned macaroon should relate
57 to.60 to.
61 :param kwargs: Additional arguments that issuers may require to
62 issue a macaroon.
58 :raises BadMacaroonContext: if the context is unsuitable.63 :raises BadMacaroonContext: if the context is unsuitable.
59 :return: A macaroon.64 :return: A macaroon.
60 """65 """
6166
=== modified file 'lib/lp/services/macaroons/model.py'
--- lib/lp/services/macaroons/model.py 2019-04-25 22:10:36 +0000
+++ lib/lp/services/macaroons/model.py 2019-05-01 16:44:22 +0000
@@ -25,6 +25,31 @@
2525
26 issuable_via_authserver = False26 issuable_via_authserver = False
2727
28 # A mapping of caveat names to "checker" callables that verify the
29 # corresponding caveat text. The signature of each checker is
30 # (caveat_value, context, **kwargs) -> bool, where caveat_value is the
31 # text of the caveat with the caveat name removed, context is the
32 # issuer-specific context to check, and kwargs is any other keyword
33 # arguments that were given to verifyMacaroon; it should return True if
34 # the caveat is allowed, otherwise False.
35 #
36 # The context passed in may be None, in which case the checker may
37 # choose to only verify that the caveat could be valid for some context,
38 # or may simply return False if this is unsupported. This is useful for
39 # issuers that support APIs with separate authentication and
40 # authorisation phases.
41 #
42 # The "primary context caveat" added to all macaroons issued by this
43 # base class does not need to be listed here; it is handled by the
44 # verifyContextCaveat method.
45 checkers = {}
46
47 # Caveat names in this set may appear more than once (in which case they
48 # have the usual subtractive semantics, so the union of all the
49 # constraints they express applies). Any other caveats may only appear
50 # once.
51 allow_multiple = set()
52
28 @property53 @property
29 def identifier(self):54 def identifier(self):
30 """An identifying name for this issuer."""55 """An identifying name for this issuer."""
@@ -43,7 +68,7 @@
43 "launchpad.internal_macaroon_secret_key not configured.")68 "launchpad.internal_macaroon_secret_key not configured.")
44 return secret69 return secret
4570
46 def checkIssuingContext(self, context):71 def checkIssuingContext(self, context, **kwargs):
47 """Check that the issuing context is suitable.72 """Check that the issuing context is suitable.
4873
49 Concrete implementations may implement this method to check that the74 Concrete implementations may implement this method to check that the
@@ -52,18 +77,16 @@
52 was passed in or an adapted one.77 was passed in or an adapted one.
5378
54 :param context: The context to check.79 :param context: The context to check.
80 :param kwargs: Additional arguments that issuers may require to
81 issue a macaroon.
55 :raises BadMacaroonContext: if the context is unsuitable.82 :raises BadMacaroonContext: if the context is unsuitable.
56 :return: The context to use to create the primary caveat.83 :return: The context to use to create the primary caveat.
57 """84 """
58 return context85 return context
5986
60 def issueMacaroon(self, context):87 def issueMacaroon(self, context, **kwargs):
61 """See `IMacaroonIssuer`.88 """See `IMacaroonIssuer`."""
6289 context = self.checkIssuingContext(context, **kwargs)
63 Concrete implementations should normally wrap this with some
64 additional checks of and/or changes to the context.
65 """
66 context = self.checkIssuingContext(context)
67 macaroon = Macaroon(90 macaroon = Macaroon(
68 location=config.vhost.mainsite.hostname,91 location=config.vhost.mainsite.hostname,
69 identifier=self.identifier, key=self._root_secret)92 identifier=self.identifier, key=self._root_secret)
@@ -71,7 +94,7 @@
71 "%s %s" % (self._primary_caveat_name, context))94 "%s %s" % (self._primary_caveat_name, context))
72 return macaroon95 return macaroon
7396
74 def checkVerificationContext(self, context):97 def checkVerificationContext(self, context, **kwargs):
75 """Check that the verification context is suitable.98 """Check that the verification context is suitable.
7699
77 Concrete implementations may implement this method to check that the100 Concrete implementations may implement this method to check that the
@@ -80,23 +103,27 @@
80 context that was passed in or an adapted one.103 context that was passed in or an adapted one.
81104
82 :param context: The context to check.105 :param context: The context to check.
106 :param kwargs: Additional arguments that issuers may require to
107 verify a macaroon.
83 :raises BadMacaroonContext: if the context is unsuitable.108 :raises BadMacaroonContext: if the context is unsuitable.
84 :return: The context to pass to individual caveat checkers.109 :return: The context to pass to individual caveat checkers.
85 """110 """
86 return context111 return context
87112
88 def verifyPrimaryCaveat(self, caveat_value, context):113 def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
89 """Verify the primary context caveat on one of this issuer's macaroons.114 """Verify the primary context caveat on one of this issuer's macaroons.
90115
91 :param caveat_value: The text of the caveat, with this issuer's116 :param caveat_value: The text of the caveat with the caveat name
92 prefix removed.117 removed.
93 :param context: The context to check.118 :param context: The context to check.
119 :param kwargs: Additional arguments that issuers may require to
120 verify a macaroon.
94 :return: True if this caveat is allowed, otherwise False.121 :return: True if this caveat is allowed, otherwise False.
95 """122 """
96 raise NotImplementedError123 raise NotImplementedError
97124
98 def verifyMacaroon(self, macaroon, context, require_context=True,125 def verifyMacaroon(self, macaroon, context, require_context=True,
99 errors=None):126 errors=None, **kwargs):
100 """See `IMacaroonIssuer`."""127 """See `IMacaroonIssuer`."""
101 if macaroon.location != config.vhost.mainsite.hostname:128 if macaroon.location != config.vhost.mainsite.hostname:
102 if errors is not None:129 if errors is not None:
@@ -115,6 +142,7 @@
115 if errors is not None:142 if errors is not None:
116 errors.append(str(e))143 errors.append(str(e))
117 return False144 return False
145 seen = set()
118146
119 def verify(caveat):147 def verify(caveat):
120 try:148 try:
@@ -123,16 +151,22 @@
123 if errors is not None:151 if errors is not None:
124 errors.append("Cannot parse caveat '%s'." % caveat)152 errors.append("Cannot parse caveat '%s'." % caveat)
125 return False153 return False
154 if caveat_name not in self.allow_multiple and caveat_name in seen:
155 if errors is not None:
156 errors.append(
157 "Multiple '%s' caveats are not allowed." % caveat_name)
158 return False
159 seen.add(caveat_name)
126 if caveat_name == self._primary_caveat_name:160 if caveat_name == self._primary_caveat_name:
127 checker = self.verifyPrimaryCaveat161 checker = self.verifyPrimaryCaveat
128 else:162 else:
129 # XXX cjwatson 2019-04-09: For now we just fail closed if163 checker = self.checkers.get(caveat_name)
130 # there are any other caveats, which is good enough for164 if checker is None:
131 # internal use.165 if errors is not None:
132 if errors is not None:166 errors.append(
133 errors.append("Unhandled caveat name '%s'." % caveat_name)167 "Unhandled caveat name '%s'." % caveat_name)
134 return False168 return False
135 if not checker(caveat_value, context):169 if not checker(caveat_value, context, **kwargs):
136 if errors is not None:170 if errors is not None:
137 errors.append("Caveat check for '%s' failed." % caveat)171 errors.append("Caveat check for '%s' failed." % caveat)
138 return False172 return False
139173
=== modified file 'lib/lp/snappy/model/snapbuild.py'
--- lib/lp/snappy/model/snapbuild.py 2019-04-25 09:34:40 +0000
+++ lib/lp/snappy/model/snapbuild.py 2019-05-01 16:44:22 +0000
@@ -599,7 +599,7 @@
599 identifier = "snap-build"599 identifier = "snap-build"
600 issuable_via_authserver = True600 issuable_via_authserver = True
601601
602 def checkIssuingContext(self, context):602 def checkIssuingContext(self, context, **kwargs):
603 """See `MacaroonIssuerBase`.603 """See `MacaroonIssuerBase`.
604604
605 For issuing, the context is an `ISnapBuild`.605 For issuing, the context is an `ISnapBuild`.
@@ -611,13 +611,13 @@
611 context, "Refusing to issue macaroon for public build.")611 context, "Refusing to issue macaroon for public build.")
612 return removeSecurityProxy(context).id612 return removeSecurityProxy(context).id
613613
614 def checkVerificationContext(self, context):614 def checkVerificationContext(self, context, **kwargs):
615 """See `MacaroonIssuerBase`."""615 """See `MacaroonIssuerBase`."""
616 if not IGitRepository.providedBy(context):616 if not IGitRepository.providedBy(context):
617 raise BadMacaroonContext(context)617 raise BadMacaroonContext(context)
618 return context618 return context
619619
620 def verifyPrimaryCaveat(self, caveat_value, context):620 def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
621 """See `MacaroonIssuerBase`.621 """See `MacaroonIssuerBase`.
622622
623 For verification, the context is an `IGitRepository`. We check that623 For verification, the context is an `IGitRepository`. We check that
624624
=== modified file 'lib/lp/soyuz/model/binarypackagebuild.py'
--- lib/lp/soyuz/model/binarypackagebuild.py 2019-04-24 12:50:20 +0000
+++ lib/lp/soyuz/model/binarypackagebuild.py 2019-05-01 16:44:22 +0000
@@ -1387,7 +1387,7 @@
1387 # the named build directly.1387 # the named build directly.
1388 return "lp.principal.binary-package-build"1388 return "lp.principal.binary-package-build"
13891389
1390 def checkIssuingContext(self, context):1390 def checkIssuingContext(self, context, **kwargs):
1391 """See `MacaroonIssuerBase`.1391 """See `MacaroonIssuerBase`.
13921392
1393 For issuing, the context is an `IBinaryPackageBuild`.1393 For issuing, the context is an `IBinaryPackageBuild`.
@@ -1397,13 +1397,13 @@
1397 context, "Refusing to issue macaroon for public build.")1397 context, "Refusing to issue macaroon for public build.")
1398 return removeSecurityProxy(context).id1398 return removeSecurityProxy(context).id
13991399
1400 def checkVerificationContext(self, context):1400 def checkVerificationContext(self, context, **kwargs):
1401 """See `MacaroonIssuerBase`."""1401 """See `MacaroonIssuerBase`."""
1402 if not ILibraryFileAlias.providedBy(context):1402 if not ILibraryFileAlias.providedBy(context):
1403 raise BadMacaroonContext(context)1403 raise BadMacaroonContext(context)
1404 return context1404 return context
14051405
1406 def verifyPrimaryCaveat(self, caveat_value, context):1406 def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
1407 """See `MacaroonIssuerBase`.1407 """See `MacaroonIssuerBase`.
14081408
1409 For verification, the context is an `ILibraryFileAlias`. We check1409 For verification, the context is an `ILibraryFileAlias`. We check