Merge ~cjwatson/launchpad:access-token-api into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 8e4c15dd7cdeae1feac4eecb3f23f5a6b2c16698
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:access-token-api
Merge into: launchpad:master
Diff against target: 795 lines (+371/-51)
12 files modified
lib/lp/_schema_circular_imports.py (+4/-0)
lib/lp/code/interfaces/gitrepository.py (+28/-5)
lib/lp/code/model/gitrepository.py (+35/-7)
lib/lp/code/model/tests/test_gitrepository.py (+72/-0)
lib/lp/services/auth/configure.zcml (+9/-0)
lib/lp/services/auth/interfaces.py (+65/-19)
lib/lp/services/auth/model.py (+40/-8)
lib/lp/services/auth/tests/test_model.py (+92/-5)
lib/lp/services/auth/webservice.py (+14/-0)
lib/lp/services/webapp/tests/test_servers.py (+3/-4)
lib/lp/services/webservice/wadl-to-refhtml.xsl (+6/-1)
lib/lp/testing/factory.py (+3/-2)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+410163@code.launchpad.net

Commit message

Add webservice API for personal access tokens

Description of the change

This currently supports issuing, querying, and revoking personal access tokens for Git repositories.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

looks good!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
2index 7c3c549..204fa61 100644
3--- a/lib/lp/_schema_circular_imports.py
4+++ b/lib/lp/_schema_circular_imports.py
5@@ -139,6 +139,7 @@ from lp.registry.interfaces.sourcepackage import (
6 from lp.registry.interfaces.ssh import ISSHKey
7 from lp.registry.interfaces.teammembership import ITeamMembership
8 from lp.registry.interfaces.wikiname import IWikiName
9+from lp.services.auth.interfaces import IAccessToken
10 from lp.services.comments.interfaces.conversation import IComment
11 from lp.services.fields import InlineObject
12 from lp.services.messages.interfaces.message import (
13@@ -691,6 +692,9 @@ patch_collection_property(
14 patch_collection_property(
15 IHasSpecifications, 'api_valid_specifications', ISpecification)
16
17+# IAccessToken
18+patch_reference_property(IAccessToken, 'git_repository', IGitRepository)
19+
20
21 ###
22 #
23diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
24index dec0bf6..363356d 100644
25--- a/lib/lp/code/interfaces/gitrepository.py
26+++ b/lib/lp/code/interfaces/gitrepository.py
27@@ -85,6 +85,8 @@ from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
28 from lp.registry.interfaces.personproduct import IPersonProductFactory
29 from lp.registry.interfaces.product import IProduct
30 from lp.registry.interfaces.role import IPersonRoles
31+from lp.services.auth.enums import AccessTokenScope
32+from lp.services.auth.interfaces import IAccessTokenTarget
33 from lp.services.fields import (
34 InlineObject,
35 PersonChoice,
36@@ -690,20 +692,41 @@ class IGitRepositoryView(IHasRecipes):
37 :return: A `ResultSet` of `IGitActivity`.
38 """
39
40+ # XXX cjwatson 2021-10-13: This should move to IAccessTokenTarget, but
41+ # currently has rather too much backward-compatibility code for that.
42+ @operation_parameters(
43+ description=TextLine(
44+ title=_("A short description of the token."), required=False),
45+ scopes=List(
46+ title=_("A list of scopes to be granted by this token."),
47+ value_type=Choice(vocabulary=AccessTokenScope), required=False),
48+ date_expires=Datetime(
49+ title=_("When the token should expire."), required=False))
50 @export_write_operation()
51 @operation_for_version("devel")
52- def issueAccessToken():
53+ def issueAccessToken(description=None, scopes=None, date_expires=None):
54 """Issue an access token for this repository.
55
56 Access tokens can be used to push to this repository over HTTPS.
57 They are only valid for a single repository, and have a short expiry
58- period (currently one week), so at the moment they are only suitable
59- in some limited situations.
60+ period (currently fixed at one week), so at the moment they are only
61+ suitable in some limited situations. By default they are currently
62+ implemented as macaroons.
63+
64+ If `description` and `scopes` are both given, then issue a personal
65+ access token instead, either non-expiring or with an expiry time
66+ given by `date_expires`. These may be used in webservice API
67+ requests for certain methods on this repository.
68
69 This interface is experimental, and may be changed or removed
70 without notice.
71
72- :return: A serialised macaroon.
73+ :return: If `description` and `scopes` are both given, the secret
74+ for a new personal access token (Launchpad only records the hash
75+ of this secret and not the secret itself, so the caller must be
76+ careful to save this; personal access tokens are in development
77+ and may not entirely work yet). Otherwise, a serialised
78+ macaroon.
79 """
80
81
82@@ -782,7 +805,7 @@ class IGitRepositoryExpensiveRequest(Interface):
83 that is not an admin or a registry expert."""
84
85
86-class IGitRepositoryEdit(IWebhookTarget):
87+class IGitRepositoryEdit(IWebhookTarget, IAccessTokenTarget):
88 """IGitRepository methods that require launchpad.Edit permission."""
89
90 @mutator_for(IGitRepositoryView["name"])
91diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
92index 8fb7cda..99d451b 100644
93--- a/lib/lp/code/model/gitrepository.py
94+++ b/lib/lp/code/model/gitrepository.py
95@@ -177,6 +177,9 @@ from lp.registry.model.accesspolicy import (
96 )
97 from lp.registry.model.person import Person
98 from lp.registry.model.teammembership import TeamParticipation
99+from lp.services.auth.interfaces import IAccessTokenSet
100+from lp.services.auth.model import AccessTokenTargetMixin
101+from lp.services.auth.utils import create_access_token_secret
102 from lp.services.config import config
103 from lp.services.database import bulk
104 from lp.services.database.constants import (
105@@ -290,7 +293,8 @@ def git_repository_modified(repository, event):
106
107
108 @implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType)
109-class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
110+class GitRepository(StormBase, WebhookTargetMixin, AccessTokenTargetMixin,
111+ GitIdentityMixin):
112 """See `IGitRepository`."""
113
114 __storm_table__ = 'GitRepository'
115@@ -1580,19 +1584,43 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
116 return DecoratedResultSet(
117 results, pre_iter_hook=preloadDataForActivities)
118
119- def issueAccessToken(self):
120- """See `IGitRepository`."""
121+ def _issuePersonalAccessToken(self, user, description, scopes,
122+ date_expires=None):
123+ """Issue a personal access token for this repository."""
124+ if user is None:
125+ raise Unauthorized(
126+ "Personal access tokens may only be issued for a logged-in "
127+ "user.")
128+ secret = create_access_token_secret()
129+ getUtility(IAccessTokenSet).new(
130+ secret, owner=user, description=description, target=self,
131+ scopes=scopes, date_expires=date_expires)
132+ return secret
133+
134+ # XXX cjwatson 2021-10-13: Remove this once lp.code.xmlrpc.git accepts
135+ # pushes using personal access tokens.
136+ def _issueMacaroon(self, user):
137+ """Issue a macaroon for this repository."""
138 issuer = getUtility(IMacaroonIssuer, "git-repository")
139+ # Our security adapter has already done the checks we need, apart
140+ # from forbidding anonymous users which is done by the issuer.
141+ return removeSecurityProxy(issuer).issueMacaroon(
142+ self, user=user).serialize()
143+
144+ def issueAccessToken(self, owner=None, description=None, scopes=None,
145+ date_expires=None):
146+ """See `IGitRepository`."""
147 # It's more usual in model code to pass the user as an argument,
148 # e.g. using @call_with(user=REQUEST_USER) in the webservice
149 # interface. However, in this case that would allow anyone who
150 # constructs a way to call this method not via the webservice to
151 # issue a token for any user, which seems like a bad idea.
152 user = getUtility(ILaunchBag).user
153- # Our security adapter has already done the checks we need, apart
154- # from forbidding anonymous users which is done by the issuer.
155- return removeSecurityProxy(issuer).issueMacaroon(
156- self, user=user).serialize()
157+ if description is not None and scopes is not None:
158+ return self._issuePersonalAccessToken(
159+ user, description, scopes, date_expires=date_expires)
160+ else:
161+ return self._issueMacaroon(user)
162
163 def canBeDeleted(self):
164 """See `IGitRepository`."""
165diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
166index 78c3353..a832554 100644
167--- a/lib/lp/code/model/tests/test_gitrepository.py
168+++ b/lib/lp/code/model/tests/test_gitrepository.py
169@@ -143,6 +143,8 @@ from lp.registry.interfaces.persondistributionsourcepackage import (
170 from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
171 from lp.registry.interfaces.personproduct import IPersonProductFactory
172 from lp.registry.tests.test_accesspolicy import get_policies_for_artifact
173+from lp.services.auth.enums import AccessTokenScope
174+from lp.services.auth.interfaces import IAccessTokenSet
175 from lp.services.authserver.xmlrpc import AuthServerAPIView
176 from lp.services.config import config
177 from lp.services.database.constants import UTC_NOW
178@@ -4717,6 +4719,76 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
179 b"git-repository macaroons may only be issued for a logged-in "
180 b"user.", response.body)
181
182+ def test_issueAccessToken_personal(self):
183+ # A user can request a personal access token via the webservice API.
184+ repository = self.factory.makeGitRepository()
185+ # Write access to the repository isn't checked at this stage
186+ # (although the access token will only be useful if the user has
187+ # some kind of write access).
188+ requester = self.factory.makePerson()
189+ with person_logged_in(requester):
190+ repository_url = api_url(repository)
191+ webservice = webservice_for_person(
192+ requester, permission=OAuthPermission.WRITE_PUBLIC,
193+ default_api_version="devel")
194+ response = webservice.named_post(
195+ repository_url, "issueAccessToken", description="Test token",
196+ scopes=["repository:build_status"])
197+ self.assertEqual(200, response.status)
198+ secret = response.jsonBody()
199+ with person_logged_in(requester):
200+ token = getUtility(IAccessTokenSet).getBySecret(secret)
201+ self.assertThat(token, MatchesStructure(
202+ owner=Equals(requester),
203+ description=Equals("Test token"),
204+ target=Equals(repository),
205+ scopes=Equals([AccessTokenScope.REPOSITORY_BUILD_STATUS]),
206+ date_expires=Is(None)))
207+
208+ def test_issueAccessToken_personal_with_expiry(self):
209+ # A user can set an expiry time when requesting a personal access
210+ # token via the webservice API.
211+ repository = self.factory.makeGitRepository()
212+ # Write access to the repository isn't checked at this stage
213+ # (although the access token will only be useful if the user has
214+ # some kind of write access).
215+ requester = self.factory.makePerson()
216+ with person_logged_in(requester):
217+ repository_url = api_url(repository)
218+ webservice = webservice_for_person(
219+ requester, permission=OAuthPermission.WRITE_PUBLIC,
220+ default_api_version="devel")
221+ date_expires = datetime.now(pytz.UTC) + timedelta(days=30)
222+ response = webservice.named_post(
223+ repository_url, "issueAccessToken", description="Test token",
224+ scopes=["repository:build_status"],
225+ date_expires=date_expires.isoformat())
226+ self.assertEqual(200, response.status)
227+ secret = response.jsonBody()
228+ with person_logged_in(requester):
229+ token = getUtility(IAccessTokenSet).getBySecret(secret)
230+ self.assertThat(token, MatchesStructure.byEquality(
231+ owner=requester,
232+ description="Test token",
233+ target=repository,
234+ scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS],
235+ date_expires=date_expires))
236+
237+ def test_issueAccessToken_personal_anonymous(self):
238+ # An anonymous user cannot request a personal access token via the
239+ # webservice API.
240+ repository = self.factory.makeGitRepository()
241+ with person_logged_in(repository.owner):
242+ repository_url = api_url(repository)
243+ webservice = webservice_for_person(None, default_api_version="devel")
244+ response = webservice.named_post(
245+ repository_url, "issueAccessToken", description="Test token",
246+ scopes=["repository:build_status"])
247+ self.assertEqual(401, response.status)
248+ self.assertEqual(
249+ b"Personal access tokens may only be issued for a logged-in user.",
250+ response.body)
251+
252
253 class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
254 """Test GitRepository macaroon issuing and verification."""
255diff --git a/lib/lp/services/auth/configure.zcml b/lib/lp/services/auth/configure.zcml
256index bd76aa6..f300218 100644
257--- a/lib/lp/services/auth/configure.zcml
258+++ b/lib/lp/services/auth/configure.zcml
259@@ -4,7 +4,9 @@
260
261 <configure
262 xmlns="http://namespaces.zope.org/zope"
263+ xmlns:browser="http://namespaces.zope.org/browser"
264 xmlns:i18n="http://namespaces.zope.org/i18n"
265+ xmlns:webservice="http://namespaces.canonical.com/webservice"
266 i18n_domain="launchpad">
267
268 <class class="lp.services.auth.model.AccessToken">
269@@ -23,4 +25,11 @@
270 provides="lp.services.auth.interfaces.IAccessTokenSet">
271 <allow interface="lp.services.auth.interfaces.IAccessTokenSet" />
272 </securedutility>
273+
274+ <browser:url
275+ for="lp.services.auth.interfaces.IAccessToken"
276+ path_expression="string:+access-token/${id}"
277+ attribute_to_parent="target" />
278+
279+ <webservice:register module="lp.services.auth.webservice" />
280 </configure>
281diff --git a/lib/lp/services/auth/interfaces.py b/lib/lp/services/auth/interfaces.py
282index b8e3e7a..7e20ee0 100644
283--- a/lib/lp/services/auth/interfaces.py
284+++ b/lib/lp/services/auth/interfaces.py
285@@ -7,9 +7,20 @@ __metaclass__ = type
286 __all__ = [
287 "IAccessToken",
288 "IAccessTokenSet",
289+ "IAccessTokenTarget",
290 "IAccessTokenVerifiedRequest",
291 ]
292
293+from lazr.restful.declarations import (
294+ call_with,
295+ export_read_operation,
296+ export_write_operation,
297+ exported,
298+ exported_as_webservice_entry,
299+ operation_for_version,
300+ operation_returns_collection_of,
301+ REQUEST_USER,
302+ )
303 from lazr.restful.fields import Reference
304 from zope.interface import Interface
305 from zope.schema import (
306@@ -22,54 +33,67 @@ from zope.schema import (
307 )
308
309 from lp import _
310-from lp.code.interfaces.gitrepository import IGitRepository
311 from lp.services.auth.enums import AccessTokenScope
312 from lp.services.fields import PublicPersonChoice
313+from lp.services.webservice.apihelpers import patch_reference_property
314
315
316+# XXX cjwatson 2021-10-13 bug=760849: "beta" is a lie to get WADL
317+# generation working. Individual attributes must set their version to
318+# "devel".
319+@exported_as_webservice_entry(as_of="beta")
320 class IAccessToken(Interface):
321 """A personal access token for the webservice API."""
322
323 id = Int(title=_("ID"), required=True, readonly=True)
324
325- date_created = Datetime(
326- title=_("When the token was created."), required=True, readonly=True)
327+ date_created = exported(Datetime(
328+ title=_("When the token was created."), required=True, readonly=True))
329
330- owner = PublicPersonChoice(
331+ owner = exported(PublicPersonChoice(
332 title=_("The person who created the token."),
333- vocabulary="ValidPersonOrTeam", required=True, readonly=True)
334+ vocabulary="ValidPersonOrTeam", required=True, readonly=True))
335
336- description = TextLine(
337- title=_("A short description of the token."), required=True)
338+ description = exported(TextLine(
339+ title=_("A short description of the token."), required=True))
340
341 git_repository = Reference(
342 title=_("The Git repository for which the token was issued."),
343- schema=IGitRepository, required=True, readonly=True)
344+ # Really IGitRepository, patched in _schema_circular_imports.py.
345+ schema=Interface, required=True, readonly=True)
346+
347+ target = exported(Reference(
348+ title=_("The target for which the token was issued."),
349+ # Really IAccessTokenTarget, patched in _schema_circular_imports.py.
350+ schema=Interface, required=True, readonly=True))
351
352- scopes = List(
353+ scopes = exported(List(
354 value_type=Choice(vocabulary=AccessTokenScope),
355 title=_("A list of scopes granted by the token."),
356- required=True, readonly=True)
357+ required=True, readonly=True))
358
359- date_last_used = Datetime(
360+ date_last_used = exported(Datetime(
361 title=_("When the token was last used."),
362- required=False, readonly=False)
363+ required=False, readonly=True))
364
365- date_expires = Datetime(
366+ date_expires = exported(Datetime(
367 title=_("When the token should expire or was revoked."),
368- required=False, readonly=False)
369+ required=False, readonly=True))
370
371 is_expired = Bool(
372 title=_("Whether this token has expired."),
373 required=False, readonly=True)
374
375- revoked_by = PublicPersonChoice(
376+ revoked_by = exported(PublicPersonChoice(
377 title=_("The person who revoked the token, if any."),
378- vocabulary="ValidPersonOrTeam", required=False, readonly=False)
379+ vocabulary="ValidPersonOrTeam", required=False, readonly=True))
380
381 def updateLastUsed():
382 """Update this token's last-used date, if possible."""
383
384+ @call_with(revoked_by=REQUEST_USER)
385+ @export_write_operation()
386+ @operation_for_version("devel")
387 def revoke(revoked_by):
388 """Revoke this token."""
389
390@@ -77,7 +101,7 @@ class IAccessToken(Interface):
391 class IAccessTokenSet(Interface):
392 """The set of all personal access tokens."""
393
394- def new(secret, owner, description, target, scopes):
395+ def new(secret, owner, description, target, scopes, date_expires=None):
396 """Return a new access token with a given secret.
397
398 :param secret: A text string.
399@@ -87,6 +111,8 @@ class IAccessTokenSet(Interface):
400 issued.
401 :param scopes: A list of `AccessTokenScope`s to be granted by the
402 token.
403+ :param date_expires: The time when this token should expire, or
404+ None.
405 """
406
407 def getBySecret(secret):
408@@ -101,12 +127,32 @@ class IAccessTokenSet(Interface):
409 :param owner: An `IPerson`.
410 """
411
412- def findByTarget(target):
413+ def findByTarget(target, visible_by_user=None):
414 """Return all access tokens for this target.
415
416- :param target: An `IGitRepository`.
417+ :param target: An `IAccessTokenTarget`.
418+ :param visible_by_user: If given, return only access tokens visible
419+ by this user.
420 """
421
422
423 class IAccessTokenVerifiedRequest(Interface):
424 """Marker interface for a request with a verified access token."""
425+
426+
427+# XXX cjwatson 2021-10-13 bug=760849: "beta" is a lie to get WADL
428+# generation working. Individual attributes must set their version to
429+# "devel".
430+@exported_as_webservice_entry(as_of="beta")
431+class IAccessTokenTarget(Interface):
432+ """An object that can be a target for access tokens."""
433+
434+ @call_with(visible_by_user=REQUEST_USER)
435+ @operation_returns_collection_of(IAccessToken)
436+ @export_read_operation()
437+ @operation_for_version("devel")
438+ def getAccessTokens(visible_by_user=None):
439+ """Return personal access tokens for this target."""
440+
441+
442+patch_reference_property(IAccessToken, "target", IAccessTokenTarget)
443diff --git a/lib/lp/services/auth/model.py b/lib/lp/services/auth/model.py
444index 437be7e..39a6684 100644
445--- a/lib/lp/services/auth/model.py
446+++ b/lib/lp/services/auth/model.py
447@@ -6,6 +6,7 @@
448 __metaclass__ = type
449 __all__ = [
450 "AccessToken",
451+ "AccessTokenTargetMixin",
452 ]
453
454 from datetime import (
455@@ -20,6 +21,7 @@ from storm.expr import (
456 And,
457 Cast,
458 Or,
459+ Select,
460 SQL,
461 Update,
462 )
463@@ -29,9 +31,13 @@ from storm.locals import (
464 Reference,
465 Unicode,
466 )
467+from zope.component import getUtility
468 from zope.interface import implementer
469+from zope.security.proxy import removeSecurityProxy
470
471+from lp.code.interfaces.gitcollection import IAllGitRepositories
472 from lp.code.interfaces.gitrepository import IGitRepository
473+from lp.registry.model.teammembership import TeamParticipation
474 from lp.services.auth.enums import AccessTokenScope
475 from lp.services.auth.interfaces import (
476 IAccessToken,
477@@ -78,7 +84,8 @@ class AccessToken(StormBase):
478
479 resolution = timedelta(minutes=10)
480
481- def __init__(self, secret, owner, description, target, scopes):
482+ def __init__(self, secret, owner, description, target, scopes,
483+ date_expires=None):
484 """Construct an `AccessToken`."""
485 self._token_sha256 = hashlib.sha256(secret.encode()).hexdigest()
486 self.owner = owner
487@@ -89,6 +96,7 @@ class AccessToken(StormBase):
488 raise TypeError("Unsupported target: {!r}".format(target))
489 self.scopes = scopes
490 self.date_created = UTC_NOW
491+ self.date_expires = date_expires
492
493 @property
494 def target(self):
495@@ -109,7 +117,8 @@ class AccessToken(StormBase):
496
497 def updateLastUsed(self):
498 """See `IAccessToken`."""
499- IMasterStore(AccessToken).execute(Update(
500+ store = IMasterStore(AccessToken)
501+ store.execute(Update(
502 {AccessToken.date_last_used: UTC_NOW},
503 where=And(
504 # Skip the update if the AccessToken row is already locked,
505@@ -124,6 +133,7 @@ class AccessToken(StormBase):
506 AccessToken.date_last_used <
507 UTC_NOW - Cast(self.resolution, 'interval'))),
508 table=AccessToken))
509+ store.invalidate(self)
510
511 @property
512 def is_expired(self):
513@@ -139,10 +149,13 @@ class AccessToken(StormBase):
514 @implementer(IAccessTokenSet)
515 class AccessTokenSet:
516
517- def new(self, secret, owner, description, target, scopes):
518+ def new(self, secret, owner, description, target, scopes,
519+ date_expires=None):
520 """See `IAccessTokenSet`."""
521 store = IStore(AccessToken)
522- token = AccessToken(secret, owner, description, target, scopes)
523+ token = AccessToken(
524+ secret, owner, description, target, scopes,
525+ date_expires=date_expires)
526 store.add(token)
527 return token
528
529@@ -156,11 +169,30 @@ class AccessTokenSet:
530 """See `IAccessTokenSet`."""
531 return IStore(AccessToken).find(AccessToken, owner=owner)
532
533- def findByTarget(self, target):
534+ def findByTarget(self, target, visible_by_user=None):
535 """See `IAccessTokenSet`."""
536- kwargs = {}
537+ clauses = []
538 if IGitRepository.providedBy(target):
539- kwargs["git_repository"] = target
540+ clauses.append(AccessToken.git_repository == target)
541+ if visible_by_user is not None:
542+ collection = getUtility(IAllGitRepositories).visibleByUser(
543+ visible_by_user).ownedByTeamMember(visible_by_user)
544+ ids = collection.getRepositoryIds()
545+ clauses.append(Or(
546+ AccessToken.owner_id.is_in(Select(
547+ TeamParticipation.teamID,
548+ where=TeamParticipation.person == visible_by_user.id)),
549+ AccessToken.git_repository_id.is_in(
550+ removeSecurityProxy(ids)._get_select())))
551 else:
552 raise TypeError("Unsupported target: {!r}".format(target))
553- return IStore(AccessToken).find(AccessToken, **kwargs)
554+ return IStore(AccessToken).find(AccessToken, *clauses)
555+
556+
557+class AccessTokenTargetMixin:
558+ """Mix this into classes that implement `IAccessTokenTarget`."""
559+
560+ def getAccessTokens(self, visible_by_user=None):
561+ """See `IAccessTokenTarget`."""
562+ return getUtility(IAccessTokenSet).findByTarget(
563+ self, visible_by_user=visible_by_user)
564diff --git a/lib/lp/services/auth/tests/test_model.py b/lib/lp/services/auth/tests/test_model.py
565index f97d498..c64dbd9 100644
566--- a/lib/lp/services/auth/tests/test_model.py
567+++ b/lib/lp/services/auth/tests/test_model.py
568@@ -31,12 +31,18 @@ from lp.services.database.sqlbase import (
569 get_transaction_timestamp,
570 )
571 from lp.services.webapp.authorization import check_permission
572+from lp.services.webapp.interfaces import OAuthPermission
573 from lp.testing import (
574+ api_url,
575 login,
576 login_person,
577+ person_logged_in,
578+ record_two_runs,
579 TestCaseWithFactory,
580 )
581 from lp.testing.layers import DatabaseFunctionalLayer
582+from lp.testing.matchers import HasQueryCount
583+from lp.testing.pages import webservice_for_person
584
585
586 class TestAccessToken(TestCaseWithFactory):
587@@ -79,7 +85,7 @@ class TestAccessToken(TestCaseWithFactory):
588 _, token = self.factory.makeAccessToken(owner=owner)
589 login_person(owner)
590 recent = datetime.now(pytz.UTC) - timedelta(minutes=1)
591- token.date_last_used = recent
592+ removeSecurityProxy(token).date_last_used = recent
593 transaction.commit()
594 token.updateLastUsed()
595 self.assertEqual(recent, token.date_last_used)
596@@ -91,7 +97,7 @@ class TestAccessToken(TestCaseWithFactory):
597 _, token = self.factory.makeAccessToken(owner=owner)
598 login_person(owner)
599 recent = datetime.now(pytz.UTC) - timedelta(hours=1)
600- token.date_last_used = recent
601+ removeSecurityProxy(token).date_last_used = recent
602 transaction.commit()
603 token.updateLastUsed()
604 now = get_transaction_timestamp(Store.of(token))
605@@ -145,9 +151,9 @@ class TestAccessToken(TestCaseWithFactory):
606 owner = self.factory.makePerson()
607 login_person(owner)
608 _, current_token = self.factory.makeAccessToken(owner=owner)
609- _, expired_token = self.factory.makeAccessToken(owner=owner)
610- expired_token.date_expires = (
611- datetime.now(pytz.UTC) - timedelta(minutes=1))
612+ _, expired_token = self.factory.makeAccessToken(
613+ owner=owner,
614+ date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
615 self.assertFalse(current_token.is_expired)
616 self.assertTrue(expired_token.is_expired)
617
618@@ -219,3 +225,84 @@ class TestAccessTokenSet(TestCaseWithFactory):
619 [tokens[2]], getUtility(IAccessTokenSet).findByTarget(targets[1]))
620 self.assertContentEqual(
621 [], getUtility(IAccessTokenSet).findByTarget(targets[2]))
622+
623+ def test_findByTarget_visible_by_user(self):
624+ targets = [self.factory.makeGitRepository() for _ in range(3)]
625+ owners = [self.factory.makePerson() for _ in range(3)]
626+ tokens = [
627+ self.factory.makeAccessToken(
628+ owner=owners[owner_index], target=targets[target_index])[1]
629+ for owner_index, target_index in (
630+ (0, 0), (0, 0), (1, 0), (1, 1), (2, 1))]
631+ for owner_index, target_index, expected_tokens in (
632+ (0, 0, tokens[:2]),
633+ (0, 1, []),
634+ (0, 2, []),
635+ (1, 0, [tokens[2]]),
636+ (1, 1, [tokens[3]]),
637+ (1, 2, []),
638+ (2, 0, []),
639+ (2, 1, [tokens[4]]),
640+ (2, 2, []),
641+ ):
642+ self.assertContentEqual(
643+ expected_tokens,
644+ getUtility(IAccessTokenSet).findByTarget(
645+ targets[target_index],
646+ visible_by_user=owners[owner_index]))
647+
648+
649+class TestAccessTokenTargetBase:
650+
651+ layer = DatabaseFunctionalLayer
652+
653+ def setUp(self):
654+ super().setUp()
655+ self.target = self.makeTarget()
656+ self.owner = self.target.owner
657+ self.target_url = api_url(self.target)
658+ self.webservice = webservice_for_person(
659+ self.owner, permission=OAuthPermission.WRITE_PRIVATE)
660+
661+ def test_getAccessTokens(self):
662+ with person_logged_in(self.owner):
663+ for description in ("Test token 1", "Test token 2"):
664+ self.factory.makeAccessToken(
665+ owner=self.owner, description=description,
666+ target=self.target)
667+ response = self.webservice.named_get(
668+ self.target_url, "getAccessTokens", api_version="devel")
669+ self.assertEqual(200, response.status)
670+ self.assertContentEqual(
671+ ["Test token 1", "Test token 2"],
672+ [entry["description"] for entry in response.jsonBody()["entries"]])
673+
674+ def test_getAccessTokens_permissions(self):
675+ webservice = webservice_for_person(None)
676+ response = webservice.named_get(
677+ self.target_url, "getAccessTokens", api_version="devel")
678+ self.assertEqual(401, response.status)
679+ self.assertIn(b"launchpad.Edit", response.body)
680+
681+ def test_getAccessTokens_query_count(self):
682+ def get_tokens():
683+ response = self.webservice.named_get(
684+ self.target_url, "getAccessTokens", api_version="devel")
685+ self.assertEqual(200, response.status)
686+ self.assertIn(len(response.jsonBody()["entries"]), {0, 2, 4})
687+
688+ def create_token():
689+ with person_logged_in(self.owner):
690+ self.factory.makeAccessToken(
691+ owner=self.owner, target=self.target)
692+
693+ get_tokens()
694+ recorder1, recorder2 = record_two_runs(get_tokens, create_token, 2)
695+ self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
696+
697+
698+class TestAccessTokenTargetGitRepository(
699+ TestAccessTokenTargetBase, TestCaseWithFactory):
700+
701+ def makeTarget(self):
702+ return self.factory.makeGitRepository()
703diff --git a/lib/lp/services/auth/webservice.py b/lib/lp/services/auth/webservice.py
704new file mode 100644
705index 0000000..08e7ca1
706--- /dev/null
707+++ b/lib/lp/services/auth/webservice.py
708@@ -0,0 +1,14 @@
709+# Copyright 2021 Canonical Ltd. This software is licensed under the
710+# GNU Affero General Public License version 3 (see the file LICENSE).
711+
712+"""Personal access token webservice registrations."""
713+
714+__all__ = [
715+ "IAccessToken",
716+ "IAccessTokenTarget",
717+ ]
718+
719+from lp.services.auth.interfaces import (
720+ IAccessToken,
721+ IAccessTokenTarget,
722+ )
723diff --git a/lib/lp/services/webapp/tests/test_servers.py b/lib/lp/services/webapp/tests/test_servers.py
724index aa5123f..a5ffe0a 100644
725--- a/lib/lp/services/webapp/tests/test_servers.py
726+++ b/lib/lp/services/webapp/tests/test_servers.py
727@@ -68,7 +68,6 @@ from lp.services.webapp.servers import (
728 from lp.testing import (
729 EventRecorder,
730 logout,
731- person_logged_in,
732 TestCase,
733 TestCaseWithFactory,
734 )
735@@ -845,9 +844,9 @@ class TestWebServiceAccessTokens(TestCaseWithFactory):
736
737 def test_expired(self):
738 owner = self.factory.makePerson()
739- secret, token = self.factory.makeAccessToken(owner=owner)
740- with person_logged_in(owner):
741- token.date_expires = datetime.now(pytz.UTC) - timedelta(days=1)
742+ secret, token = self.factory.makeAccessToken(
743+ owner=owner,
744+ date_expires=datetime.now(pytz.UTC) - timedelta(days=1))
745 transaction.commit()
746
747 request, publication = get_request_and_publication(
748diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
749index 96efcf6..dc13955 100644
750--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
751+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
752@@ -166,7 +166,8 @@
753 <xsl:with-param name="url">
754 <xsl:choose>
755 <xsl:when test="
756- @id = 'bug_link_target'
757+ @id = 'access_token_target'
758+ or @id = 'bug_link_target'
759 or @id = 'bug_target'
760 or @id = 'faq_target'
761 or @id = 'git_target'
762@@ -190,6 +191,10 @@
763 <xsl:template name="find-entry-uri">
764 <xsl:value-of select="$base"/>
765 <xsl:choose>
766+ <xsl:when test="@id = 'access_token'">
767+ <xsl:text>/[target URL]/+access-token/</xsl:text>
768+ <var>&lt;id&gt;</var>
769+ </xsl:when>
770 <xsl:when test="@id = 'archive'">
771 <xsl:text>/</xsl:text>
772 <var>&lt;distribution&gt;</var>
773diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
774index 4fccdec..50f2ed4 100644
775--- a/lib/lp/testing/factory.py
776+++ b/lib/lp/testing/factory.py
777@@ -4519,7 +4519,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
778 return request_token.createAccessToken()
779
780 def makeAccessToken(self, secret=None, owner=None, description=None,
781- target=None, scopes=None):
782+ target=None, scopes=None, date_expires=None):
783 """Create a personal access token.
784
785 :return: A tuple of the secret for the new token and the token
786@@ -4536,7 +4536,8 @@ class BareLaunchpadObjectFactory(ObjectFactory):
787 if scopes is None:
788 scopes = []
789 token = getUtility(IAccessTokenSet).new(
790- secret, owner, description, target, scopes)
791+ secret, owner, description, target, scopes,
792+ date_expires=date_expires)
793 return secret, token
794
795 def makeCVE(self, sequence, description=None,

Subscribers

People subscribed via source and target branches

to status/vote changes: