Merge lp:~cjwatson/launchpad/git-issue-access-tokens into lp:launchpad
- git-issue-access-tokens
- Merge into devel
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 |
Related bugs: |
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 |