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
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index 7c3c549..204fa61 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -139,6 +139,7 @@ from lp.registry.interfaces.sourcepackage import (
139from lp.registry.interfaces.ssh import ISSHKey139from lp.registry.interfaces.ssh import ISSHKey
140from lp.registry.interfaces.teammembership import ITeamMembership140from lp.registry.interfaces.teammembership import ITeamMembership
141from lp.registry.interfaces.wikiname import IWikiName141from lp.registry.interfaces.wikiname import IWikiName
142from lp.services.auth.interfaces import IAccessToken
142from lp.services.comments.interfaces.conversation import IComment143from lp.services.comments.interfaces.conversation import IComment
143from lp.services.fields import InlineObject144from lp.services.fields import InlineObject
144from lp.services.messages.interfaces.message import (145from lp.services.messages.interfaces.message import (
@@ -691,6 +692,9 @@ patch_collection_property(
691patch_collection_property(692patch_collection_property(
692 IHasSpecifications, 'api_valid_specifications', ISpecification)693 IHasSpecifications, 'api_valid_specifications', ISpecification)
693694
695# IAccessToken
696patch_reference_property(IAccessToken, 'git_repository', IGitRepository)
697
694698
695###699###
696#700#
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index dec0bf6..363356d 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -85,6 +85,8 @@ from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
85from lp.registry.interfaces.personproduct import IPersonProductFactory85from lp.registry.interfaces.personproduct import IPersonProductFactory
86from lp.registry.interfaces.product import IProduct86from lp.registry.interfaces.product import IProduct
87from lp.registry.interfaces.role import IPersonRoles87from lp.registry.interfaces.role import IPersonRoles
88from lp.services.auth.enums import AccessTokenScope
89from lp.services.auth.interfaces import IAccessTokenTarget
88from lp.services.fields import (90from lp.services.fields import (
89 InlineObject,91 InlineObject,
90 PersonChoice,92 PersonChoice,
@@ -690,20 +692,41 @@ class IGitRepositoryView(IHasRecipes):
690 :return: A `ResultSet` of `IGitActivity`.692 :return: A `ResultSet` of `IGitActivity`.
691 """693 """
692694
695 # XXX cjwatson 2021-10-13: This should move to IAccessTokenTarget, but
696 # currently has rather too much backward-compatibility code for that.
697 @operation_parameters(
698 description=TextLine(
699 title=_("A short description of the token."), required=False),
700 scopes=List(
701 title=_("A list of scopes to be granted by this token."),
702 value_type=Choice(vocabulary=AccessTokenScope), required=False),
703 date_expires=Datetime(
704 title=_("When the token should expire."), required=False))
693 @export_write_operation()705 @export_write_operation()
694 @operation_for_version("devel")706 @operation_for_version("devel")
695 def issueAccessToken():707 def issueAccessToken(description=None, scopes=None, date_expires=None):
696 """Issue an access token for this repository.708 """Issue an access token for this repository.
697709
698 Access tokens can be used to push to this repository over HTTPS.710 Access tokens can be used to push to this repository over HTTPS.
699 They are only valid for a single repository, and have a short expiry711 They are only valid for a single repository, and have a short expiry
700 period (currently one week), so at the moment they are only suitable712 period (currently fixed at one week), so at the moment they are only
701 in some limited situations.713 suitable in some limited situations. By default they are currently
714 implemented as macaroons.
715
716 If `description` and `scopes` are both given, then issue a personal
717 access token instead, either non-expiring or with an expiry time
718 given by `date_expires`. These may be used in webservice API
719 requests for certain methods on this repository.
702720
703 This interface is experimental, and may be changed or removed721 This interface is experimental, and may be changed or removed
704 without notice.722 without notice.
705723
706 :return: A serialised macaroon.724 :return: If `description` and `scopes` are both given, the secret
725 for a new personal access token (Launchpad only records the hash
726 of this secret and not the secret itself, so the caller must be
727 careful to save this; personal access tokens are in development
728 and may not entirely work yet). Otherwise, a serialised
729 macaroon.
707 """730 """
708731
709732
@@ -782,7 +805,7 @@ class IGitRepositoryExpensiveRequest(Interface):
782 that is not an admin or a registry expert."""805 that is not an admin or a registry expert."""
783806
784807
785class IGitRepositoryEdit(IWebhookTarget):808class IGitRepositoryEdit(IWebhookTarget, IAccessTokenTarget):
786 """IGitRepository methods that require launchpad.Edit permission."""809 """IGitRepository methods that require launchpad.Edit permission."""
787810
788 @mutator_for(IGitRepositoryView["name"])811 @mutator_for(IGitRepositoryView["name"])
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 8fb7cda..99d451b 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -177,6 +177,9 @@ from lp.registry.model.accesspolicy import (
177 )177 )
178from lp.registry.model.person import Person178from lp.registry.model.person import Person
179from lp.registry.model.teammembership import TeamParticipation179from lp.registry.model.teammembership import TeamParticipation
180from lp.services.auth.interfaces import IAccessTokenSet
181from lp.services.auth.model import AccessTokenTargetMixin
182from lp.services.auth.utils import create_access_token_secret
180from lp.services.config import config183from lp.services.config import config
181from lp.services.database import bulk184from lp.services.database import bulk
182from lp.services.database.constants import (185from lp.services.database.constants import (
@@ -290,7 +293,8 @@ def git_repository_modified(repository, event):
290293
291294
292@implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType)295@implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType)
293class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):296class GitRepository(StormBase, WebhookTargetMixin, AccessTokenTargetMixin,
297 GitIdentityMixin):
294 """See `IGitRepository`."""298 """See `IGitRepository`."""
295299
296 __storm_table__ = 'GitRepository'300 __storm_table__ = 'GitRepository'
@@ -1580,19 +1584,43 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
1580 return DecoratedResultSet(1584 return DecoratedResultSet(
1581 results, pre_iter_hook=preloadDataForActivities)1585 results, pre_iter_hook=preloadDataForActivities)
15821586
1583 def issueAccessToken(self):1587 def _issuePersonalAccessToken(self, user, description, scopes,
1584 """See `IGitRepository`."""1588 date_expires=None):
1589 """Issue a personal access token for this repository."""
1590 if user is None:
1591 raise Unauthorized(
1592 "Personal access tokens may only be issued for a logged-in "
1593 "user.")
1594 secret = create_access_token_secret()
1595 getUtility(IAccessTokenSet).new(
1596 secret, owner=user, description=description, target=self,
1597 scopes=scopes, date_expires=date_expires)
1598 return secret
1599
1600 # XXX cjwatson 2021-10-13: Remove this once lp.code.xmlrpc.git accepts
1601 # pushes using personal access tokens.
1602 def _issueMacaroon(self, user):
1603 """Issue a macaroon for this repository."""
1585 issuer = getUtility(IMacaroonIssuer, "git-repository")1604 issuer = getUtility(IMacaroonIssuer, "git-repository")
1605 # Our security adapter has already done the checks we need, apart
1606 # from forbidding anonymous users which is done by the issuer.
1607 return removeSecurityProxy(issuer).issueMacaroon(
1608 self, user=user).serialize()
1609
1610 def issueAccessToken(self, owner=None, description=None, scopes=None,
1611 date_expires=None):
1612 """See `IGitRepository`."""
1586 # It's more usual in model code to pass the user as an argument,1613 # It's more usual in model code to pass the user as an argument,
1587 # e.g. using @call_with(user=REQUEST_USER) in the webservice1614 # e.g. using @call_with(user=REQUEST_USER) in the webservice
1588 # interface. However, in this case that would allow anyone who1615 # interface. However, in this case that would allow anyone who
1589 # constructs a way to call this method not via the webservice to1616 # constructs a way to call this method not via the webservice to
1590 # issue a token for any user, which seems like a bad idea.1617 # issue a token for any user, which seems like a bad idea.
1591 user = getUtility(ILaunchBag).user1618 user = getUtility(ILaunchBag).user
1592 # Our security adapter has already done the checks we need, apart1619 if description is not None and scopes is not None:
1593 # from forbidding anonymous users which is done by the issuer.1620 return self._issuePersonalAccessToken(
1594 return removeSecurityProxy(issuer).issueMacaroon(1621 user, description, scopes, date_expires=date_expires)
1595 self, user=user).serialize()1622 else:
1623 return self._issueMacaroon(user)
15961624
1597 def canBeDeleted(self):1625 def canBeDeleted(self):
1598 """See `IGitRepository`."""1626 """See `IGitRepository`."""
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 78c3353..a832554 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -143,6 +143,8 @@ from lp.registry.interfaces.persondistributionsourcepackage import (
143from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory143from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
144from lp.registry.interfaces.personproduct import IPersonProductFactory144from lp.registry.interfaces.personproduct import IPersonProductFactory
145from lp.registry.tests.test_accesspolicy import get_policies_for_artifact145from lp.registry.tests.test_accesspolicy import get_policies_for_artifact
146from lp.services.auth.enums import AccessTokenScope
147from lp.services.auth.interfaces import IAccessTokenSet
146from lp.services.authserver.xmlrpc import AuthServerAPIView148from lp.services.authserver.xmlrpc import AuthServerAPIView
147from lp.services.config import config149from lp.services.config import config
148from lp.services.database.constants import UTC_NOW150from lp.services.database.constants import UTC_NOW
@@ -4717,6 +4719,76 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
4717 b"git-repository macaroons may only be issued for a logged-in "4719 b"git-repository macaroons may only be issued for a logged-in "
4718 b"user.", response.body)4720 b"user.", response.body)
47194721
4722 def test_issueAccessToken_personal(self):
4723 # A user can request a personal access token via the webservice API.
4724 repository = self.factory.makeGitRepository()
4725 # Write access to the repository isn't checked at this stage
4726 # (although the access token will only be useful if the user has
4727 # some kind of write access).
4728 requester = self.factory.makePerson()
4729 with person_logged_in(requester):
4730 repository_url = api_url(repository)
4731 webservice = webservice_for_person(
4732 requester, permission=OAuthPermission.WRITE_PUBLIC,
4733 default_api_version="devel")
4734 response = webservice.named_post(
4735 repository_url, "issueAccessToken", description="Test token",
4736 scopes=["repository:build_status"])
4737 self.assertEqual(200, response.status)
4738 secret = response.jsonBody()
4739 with person_logged_in(requester):
4740 token = getUtility(IAccessTokenSet).getBySecret(secret)
4741 self.assertThat(token, MatchesStructure(
4742 owner=Equals(requester),
4743 description=Equals("Test token"),
4744 target=Equals(repository),
4745 scopes=Equals([AccessTokenScope.REPOSITORY_BUILD_STATUS]),
4746 date_expires=Is(None)))
4747
4748 def test_issueAccessToken_personal_with_expiry(self):
4749 # A user can set an expiry time when requesting a personal access
4750 # token via the webservice API.
4751 repository = self.factory.makeGitRepository()
4752 # Write access to the repository isn't checked at this stage
4753 # (although the access token will only be useful if the user has
4754 # some kind of write access).
4755 requester = self.factory.makePerson()
4756 with person_logged_in(requester):
4757 repository_url = api_url(repository)
4758 webservice = webservice_for_person(
4759 requester, permission=OAuthPermission.WRITE_PUBLIC,
4760 default_api_version="devel")
4761 date_expires = datetime.now(pytz.UTC) + timedelta(days=30)
4762 response = webservice.named_post(
4763 repository_url, "issueAccessToken", description="Test token",
4764 scopes=["repository:build_status"],
4765 date_expires=date_expires.isoformat())
4766 self.assertEqual(200, response.status)
4767 secret = response.jsonBody()
4768 with person_logged_in(requester):
4769 token = getUtility(IAccessTokenSet).getBySecret(secret)
4770 self.assertThat(token, MatchesStructure.byEquality(
4771 owner=requester,
4772 description="Test token",
4773 target=repository,
4774 scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS],
4775 date_expires=date_expires))
4776
4777 def test_issueAccessToken_personal_anonymous(self):
4778 # An anonymous user cannot request a personal access token via the
4779 # webservice API.
4780 repository = self.factory.makeGitRepository()
4781 with person_logged_in(repository.owner):
4782 repository_url = api_url(repository)
4783 webservice = webservice_for_person(None, default_api_version="devel")
4784 response = webservice.named_post(
4785 repository_url, "issueAccessToken", description="Test token",
4786 scopes=["repository:build_status"])
4787 self.assertEqual(401, response.status)
4788 self.assertEqual(
4789 b"Personal access tokens may only be issued for a logged-in user.",
4790 response.body)
4791
47204792
4721class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):4793class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
4722 """Test GitRepository macaroon issuing and verification."""4794 """Test GitRepository macaroon issuing and verification."""
diff --git a/lib/lp/services/auth/configure.zcml b/lib/lp/services/auth/configure.zcml
index bd76aa6..f300218 100644
--- a/lib/lp/services/auth/configure.zcml
+++ b/lib/lp/services/auth/configure.zcml
@@ -4,7 +4,9 @@
44
5<configure5<configure
6 xmlns="http://namespaces.zope.org/zope"6 xmlns="http://namespaces.zope.org/zope"
7 xmlns:browser="http://namespaces.zope.org/browser"
7 xmlns:i18n="http://namespaces.zope.org/i18n"8 xmlns:i18n="http://namespaces.zope.org/i18n"
9 xmlns:webservice="http://namespaces.canonical.com/webservice"
8 i18n_domain="launchpad">10 i18n_domain="launchpad">
911
10 <class class="lp.services.auth.model.AccessToken">12 <class class="lp.services.auth.model.AccessToken">
@@ -23,4 +25,11 @@
23 provides="lp.services.auth.interfaces.IAccessTokenSet">25 provides="lp.services.auth.interfaces.IAccessTokenSet">
24 <allow interface="lp.services.auth.interfaces.IAccessTokenSet" />26 <allow interface="lp.services.auth.interfaces.IAccessTokenSet" />
25 </securedutility>27 </securedutility>
28
29 <browser:url
30 for="lp.services.auth.interfaces.IAccessToken"
31 path_expression="string:+access-token/${id}"
32 attribute_to_parent="target" />
33
34 <webservice:register module="lp.services.auth.webservice" />
26</configure>35</configure>
diff --git a/lib/lp/services/auth/interfaces.py b/lib/lp/services/auth/interfaces.py
index b8e3e7a..7e20ee0 100644
--- a/lib/lp/services/auth/interfaces.py
+++ b/lib/lp/services/auth/interfaces.py
@@ -7,9 +7,20 @@ __metaclass__ = type
7__all__ = [7__all__ = [
8 "IAccessToken",8 "IAccessToken",
9 "IAccessTokenSet",9 "IAccessTokenSet",
10 "IAccessTokenTarget",
10 "IAccessTokenVerifiedRequest",11 "IAccessTokenVerifiedRequest",
11 ]12 ]
1213
14from lazr.restful.declarations import (
15 call_with,
16 export_read_operation,
17 export_write_operation,
18 exported,
19 exported_as_webservice_entry,
20 operation_for_version,
21 operation_returns_collection_of,
22 REQUEST_USER,
23 )
13from lazr.restful.fields import Reference24from lazr.restful.fields import Reference
14from zope.interface import Interface25from zope.interface import Interface
15from zope.schema import (26from zope.schema import (
@@ -22,54 +33,67 @@ from zope.schema import (
22 )33 )
2334
24from lp import _35from lp import _
25from lp.code.interfaces.gitrepository import IGitRepository
26from lp.services.auth.enums import AccessTokenScope36from lp.services.auth.enums import AccessTokenScope
27from lp.services.fields import PublicPersonChoice37from lp.services.fields import PublicPersonChoice
38from lp.services.webservice.apihelpers import patch_reference_property
2839
2940
41# XXX cjwatson 2021-10-13 bug=760849: "beta" is a lie to get WADL
42# generation working. Individual attributes must set their version to
43# "devel".
44@exported_as_webservice_entry(as_of="beta")
30class IAccessToken(Interface):45class IAccessToken(Interface):
31 """A personal access token for the webservice API."""46 """A personal access token for the webservice API."""
3247
33 id = Int(title=_("ID"), required=True, readonly=True)48 id = Int(title=_("ID"), required=True, readonly=True)
3449
35 date_created = Datetime(50 date_created = exported(Datetime(
36 title=_("When the token was created."), required=True, readonly=True)51 title=_("When the token was created."), required=True, readonly=True))
3752
38 owner = PublicPersonChoice(53 owner = exported(PublicPersonChoice(
39 title=_("The person who created the token."),54 title=_("The person who created the token."),
40 vocabulary="ValidPersonOrTeam", required=True, readonly=True)55 vocabulary="ValidPersonOrTeam", required=True, readonly=True))
4156
42 description = TextLine(57 description = exported(TextLine(
43 title=_("A short description of the token."), required=True)58 title=_("A short description of the token."), required=True))
4459
45 git_repository = Reference(60 git_repository = Reference(
46 title=_("The Git repository for which the token was issued."),61 title=_("The Git repository for which the token was issued."),
47 schema=IGitRepository, required=True, readonly=True)62 # Really IGitRepository, patched in _schema_circular_imports.py.
63 schema=Interface, required=True, readonly=True)
64
65 target = exported(Reference(
66 title=_("The target for which the token was issued."),
67 # Really IAccessTokenTarget, patched in _schema_circular_imports.py.
68 schema=Interface, required=True, readonly=True))
4869
49 scopes = List(70 scopes = exported(List(
50 value_type=Choice(vocabulary=AccessTokenScope),71 value_type=Choice(vocabulary=AccessTokenScope),
51 title=_("A list of scopes granted by the token."),72 title=_("A list of scopes granted by the token."),
52 required=True, readonly=True)73 required=True, readonly=True))
5374
54 date_last_used = Datetime(75 date_last_used = exported(Datetime(
55 title=_("When the token was last used."),76 title=_("When the token was last used."),
56 required=False, readonly=False)77 required=False, readonly=True))
5778
58 date_expires = Datetime(79 date_expires = exported(Datetime(
59 title=_("When the token should expire or was revoked."),80 title=_("When the token should expire or was revoked."),
60 required=False, readonly=False)81 required=False, readonly=True))
6182
62 is_expired = Bool(83 is_expired = Bool(
63 title=_("Whether this token has expired."),84 title=_("Whether this token has expired."),
64 required=False, readonly=True)85 required=False, readonly=True)
6586
66 revoked_by = PublicPersonChoice(87 revoked_by = exported(PublicPersonChoice(
67 title=_("The person who revoked the token, if any."),88 title=_("The person who revoked the token, if any."),
68 vocabulary="ValidPersonOrTeam", required=False, readonly=False)89 vocabulary="ValidPersonOrTeam", required=False, readonly=True))
6990
70 def updateLastUsed():91 def updateLastUsed():
71 """Update this token's last-used date, if possible."""92 """Update this token's last-used date, if possible."""
7293
94 @call_with(revoked_by=REQUEST_USER)
95 @export_write_operation()
96 @operation_for_version("devel")
73 def revoke(revoked_by):97 def revoke(revoked_by):
74 """Revoke this token."""98 """Revoke this token."""
7599
@@ -77,7 +101,7 @@ class IAccessToken(Interface):
77class IAccessTokenSet(Interface):101class IAccessTokenSet(Interface):
78 """The set of all personal access tokens."""102 """The set of all personal access tokens."""
79103
80 def new(secret, owner, description, target, scopes):104 def new(secret, owner, description, target, scopes, date_expires=None):
81 """Return a new access token with a given secret.105 """Return a new access token with a given secret.
82106
83 :param secret: A text string.107 :param secret: A text string.
@@ -87,6 +111,8 @@ class IAccessTokenSet(Interface):
87 issued.111 issued.
88 :param scopes: A list of `AccessTokenScope`s to be granted by the112 :param scopes: A list of `AccessTokenScope`s to be granted by the
89 token.113 token.
114 :param date_expires: The time when this token should expire, or
115 None.
90 """116 """
91117
92 def getBySecret(secret):118 def getBySecret(secret):
@@ -101,12 +127,32 @@ class IAccessTokenSet(Interface):
101 :param owner: An `IPerson`.127 :param owner: An `IPerson`.
102 """128 """
103129
104 def findByTarget(target):130 def findByTarget(target, visible_by_user=None):
105 """Return all access tokens for this target.131 """Return all access tokens for this target.
106132
107 :param target: An `IGitRepository`.133 :param target: An `IAccessTokenTarget`.
134 :param visible_by_user: If given, return only access tokens visible
135 by this user.
108 """136 """
109137
110138
111class IAccessTokenVerifiedRequest(Interface):139class IAccessTokenVerifiedRequest(Interface):
112 """Marker interface for a request with a verified access token."""140 """Marker interface for a request with a verified access token."""
141
142
143# XXX cjwatson 2021-10-13 bug=760849: "beta" is a lie to get WADL
144# generation working. Individual attributes must set their version to
145# "devel".
146@exported_as_webservice_entry(as_of="beta")
147class IAccessTokenTarget(Interface):
148 """An object that can be a target for access tokens."""
149
150 @call_with(visible_by_user=REQUEST_USER)
151 @operation_returns_collection_of(IAccessToken)
152 @export_read_operation()
153 @operation_for_version("devel")
154 def getAccessTokens(visible_by_user=None):
155 """Return personal access tokens for this target."""
156
157
158patch_reference_property(IAccessToken, "target", IAccessTokenTarget)
diff --git a/lib/lp/services/auth/model.py b/lib/lp/services/auth/model.py
index 437be7e..39a6684 100644
--- a/lib/lp/services/auth/model.py
+++ b/lib/lp/services/auth/model.py
@@ -6,6 +6,7 @@
6__metaclass__ = type6__metaclass__ = type
7__all__ = [7__all__ = [
8 "AccessToken",8 "AccessToken",
9 "AccessTokenTargetMixin",
9 ]10 ]
1011
11from datetime import (12from datetime import (
@@ -20,6 +21,7 @@ from storm.expr import (
20 And,21 And,
21 Cast,22 Cast,
22 Or,23 Or,
24 Select,
23 SQL,25 SQL,
24 Update,26 Update,
25 )27 )
@@ -29,9 +31,13 @@ from storm.locals import (
29 Reference,31 Reference,
30 Unicode,32 Unicode,
31 )33 )
34from zope.component import getUtility
32from zope.interface import implementer35from zope.interface import implementer
36from zope.security.proxy import removeSecurityProxy
3337
38from lp.code.interfaces.gitcollection import IAllGitRepositories
34from lp.code.interfaces.gitrepository import IGitRepository39from lp.code.interfaces.gitrepository import IGitRepository
40from lp.registry.model.teammembership import TeamParticipation
35from lp.services.auth.enums import AccessTokenScope41from lp.services.auth.enums import AccessTokenScope
36from lp.services.auth.interfaces import (42from lp.services.auth.interfaces import (
37 IAccessToken,43 IAccessToken,
@@ -78,7 +84,8 @@ class AccessToken(StormBase):
7884
79 resolution = timedelta(minutes=10)85 resolution = timedelta(minutes=10)
8086
81 def __init__(self, secret, owner, description, target, scopes):87 def __init__(self, secret, owner, description, target, scopes,
88 date_expires=None):
82 """Construct an `AccessToken`."""89 """Construct an `AccessToken`."""
83 self._token_sha256 = hashlib.sha256(secret.encode()).hexdigest()90 self._token_sha256 = hashlib.sha256(secret.encode()).hexdigest()
84 self.owner = owner91 self.owner = owner
@@ -89,6 +96,7 @@ class AccessToken(StormBase):
89 raise TypeError("Unsupported target: {!r}".format(target))96 raise TypeError("Unsupported target: {!r}".format(target))
90 self.scopes = scopes97 self.scopes = scopes
91 self.date_created = UTC_NOW98 self.date_created = UTC_NOW
99 self.date_expires = date_expires
92100
93 @property101 @property
94 def target(self):102 def target(self):
@@ -109,7 +117,8 @@ class AccessToken(StormBase):
109117
110 def updateLastUsed(self):118 def updateLastUsed(self):
111 """See `IAccessToken`."""119 """See `IAccessToken`."""
112 IMasterStore(AccessToken).execute(Update(120 store = IMasterStore(AccessToken)
121 store.execute(Update(
113 {AccessToken.date_last_used: UTC_NOW},122 {AccessToken.date_last_used: UTC_NOW},
114 where=And(123 where=And(
115 # Skip the update if the AccessToken row is already locked,124 # Skip the update if the AccessToken row is already locked,
@@ -124,6 +133,7 @@ class AccessToken(StormBase):
124 AccessToken.date_last_used <133 AccessToken.date_last_used <
125 UTC_NOW - Cast(self.resolution, 'interval'))),134 UTC_NOW - Cast(self.resolution, 'interval'))),
126 table=AccessToken))135 table=AccessToken))
136 store.invalidate(self)
127137
128 @property138 @property
129 def is_expired(self):139 def is_expired(self):
@@ -139,10 +149,13 @@ class AccessToken(StormBase):
139@implementer(IAccessTokenSet)149@implementer(IAccessTokenSet)
140class AccessTokenSet:150class AccessTokenSet:
141151
142 def new(self, secret, owner, description, target, scopes):152 def new(self, secret, owner, description, target, scopes,
153 date_expires=None):
143 """See `IAccessTokenSet`."""154 """See `IAccessTokenSet`."""
144 store = IStore(AccessToken)155 store = IStore(AccessToken)
145 token = AccessToken(secret, owner, description, target, scopes)156 token = AccessToken(
157 secret, owner, description, target, scopes,
158 date_expires=date_expires)
146 store.add(token)159 store.add(token)
147 return token160 return token
148161
@@ -156,11 +169,30 @@ class AccessTokenSet:
156 """See `IAccessTokenSet`."""169 """See `IAccessTokenSet`."""
157 return IStore(AccessToken).find(AccessToken, owner=owner)170 return IStore(AccessToken).find(AccessToken, owner=owner)
158171
159 def findByTarget(self, target):172 def findByTarget(self, target, visible_by_user=None):
160 """See `IAccessTokenSet`."""173 """See `IAccessTokenSet`."""
161 kwargs = {}174 clauses = []
162 if IGitRepository.providedBy(target):175 if IGitRepository.providedBy(target):
163 kwargs["git_repository"] = target176 clauses.append(AccessToken.git_repository == target)
177 if visible_by_user is not None:
178 collection = getUtility(IAllGitRepositories).visibleByUser(
179 visible_by_user).ownedByTeamMember(visible_by_user)
180 ids = collection.getRepositoryIds()
181 clauses.append(Or(
182 AccessToken.owner_id.is_in(Select(
183 TeamParticipation.teamID,
184 where=TeamParticipation.person == visible_by_user.id)),
185 AccessToken.git_repository_id.is_in(
186 removeSecurityProxy(ids)._get_select())))
164 else:187 else:
165 raise TypeError("Unsupported target: {!r}".format(target))188 raise TypeError("Unsupported target: {!r}".format(target))
166 return IStore(AccessToken).find(AccessToken, **kwargs)189 return IStore(AccessToken).find(AccessToken, *clauses)
190
191
192class AccessTokenTargetMixin:
193 """Mix this into classes that implement `IAccessTokenTarget`."""
194
195 def getAccessTokens(self, visible_by_user=None):
196 """See `IAccessTokenTarget`."""
197 return getUtility(IAccessTokenSet).findByTarget(
198 self, visible_by_user=visible_by_user)
diff --git a/lib/lp/services/auth/tests/test_model.py b/lib/lp/services/auth/tests/test_model.py
index f97d498..c64dbd9 100644
--- a/lib/lp/services/auth/tests/test_model.py
+++ b/lib/lp/services/auth/tests/test_model.py
@@ -31,12 +31,18 @@ from lp.services.database.sqlbase import (
31 get_transaction_timestamp,31 get_transaction_timestamp,
32 )32 )
33from lp.services.webapp.authorization import check_permission33from lp.services.webapp.authorization import check_permission
34from lp.services.webapp.interfaces import OAuthPermission
34from lp.testing import (35from lp.testing import (
36 api_url,
35 login,37 login,
36 login_person,38 login_person,
39 person_logged_in,
40 record_two_runs,
37 TestCaseWithFactory,41 TestCaseWithFactory,
38 )42 )
39from lp.testing.layers import DatabaseFunctionalLayer43from lp.testing.layers import DatabaseFunctionalLayer
44from lp.testing.matchers import HasQueryCount
45from lp.testing.pages import webservice_for_person
4046
4147
42class TestAccessToken(TestCaseWithFactory):48class TestAccessToken(TestCaseWithFactory):
@@ -79,7 +85,7 @@ class TestAccessToken(TestCaseWithFactory):
79 _, token = self.factory.makeAccessToken(owner=owner)85 _, token = self.factory.makeAccessToken(owner=owner)
80 login_person(owner)86 login_person(owner)
81 recent = datetime.now(pytz.UTC) - timedelta(minutes=1)87 recent = datetime.now(pytz.UTC) - timedelta(minutes=1)
82 token.date_last_used = recent88 removeSecurityProxy(token).date_last_used = recent
83 transaction.commit()89 transaction.commit()
84 token.updateLastUsed()90 token.updateLastUsed()
85 self.assertEqual(recent, token.date_last_used)91 self.assertEqual(recent, token.date_last_used)
@@ -91,7 +97,7 @@ class TestAccessToken(TestCaseWithFactory):
91 _, token = self.factory.makeAccessToken(owner=owner)97 _, token = self.factory.makeAccessToken(owner=owner)
92 login_person(owner)98 login_person(owner)
93 recent = datetime.now(pytz.UTC) - timedelta(hours=1)99 recent = datetime.now(pytz.UTC) - timedelta(hours=1)
94 token.date_last_used = recent100 removeSecurityProxy(token).date_last_used = recent
95 transaction.commit()101 transaction.commit()
96 token.updateLastUsed()102 token.updateLastUsed()
97 now = get_transaction_timestamp(Store.of(token))103 now = get_transaction_timestamp(Store.of(token))
@@ -145,9 +151,9 @@ class TestAccessToken(TestCaseWithFactory):
145 owner = self.factory.makePerson()151 owner = self.factory.makePerson()
146 login_person(owner)152 login_person(owner)
147 _, current_token = self.factory.makeAccessToken(owner=owner)153 _, current_token = self.factory.makeAccessToken(owner=owner)
148 _, expired_token = self.factory.makeAccessToken(owner=owner)154 _, expired_token = self.factory.makeAccessToken(
149 expired_token.date_expires = (155 owner=owner,
150 datetime.now(pytz.UTC) - timedelta(minutes=1))156 date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
151 self.assertFalse(current_token.is_expired)157 self.assertFalse(current_token.is_expired)
152 self.assertTrue(expired_token.is_expired)158 self.assertTrue(expired_token.is_expired)
153159
@@ -219,3 +225,84 @@ class TestAccessTokenSet(TestCaseWithFactory):
219 [tokens[2]], getUtility(IAccessTokenSet).findByTarget(targets[1]))225 [tokens[2]], getUtility(IAccessTokenSet).findByTarget(targets[1]))
220 self.assertContentEqual(226 self.assertContentEqual(
221 [], getUtility(IAccessTokenSet).findByTarget(targets[2]))227 [], getUtility(IAccessTokenSet).findByTarget(targets[2]))
228
229 def test_findByTarget_visible_by_user(self):
230 targets = [self.factory.makeGitRepository() for _ in range(3)]
231 owners = [self.factory.makePerson() for _ in range(3)]
232 tokens = [
233 self.factory.makeAccessToken(
234 owner=owners[owner_index], target=targets[target_index])[1]
235 for owner_index, target_index in (
236 (0, 0), (0, 0), (1, 0), (1, 1), (2, 1))]
237 for owner_index, target_index, expected_tokens in (
238 (0, 0, tokens[:2]),
239 (0, 1, []),
240 (0, 2, []),
241 (1, 0, [tokens[2]]),
242 (1, 1, [tokens[3]]),
243 (1, 2, []),
244 (2, 0, []),
245 (2, 1, [tokens[4]]),
246 (2, 2, []),
247 ):
248 self.assertContentEqual(
249 expected_tokens,
250 getUtility(IAccessTokenSet).findByTarget(
251 targets[target_index],
252 visible_by_user=owners[owner_index]))
253
254
255class TestAccessTokenTargetBase:
256
257 layer = DatabaseFunctionalLayer
258
259 def setUp(self):
260 super().setUp()
261 self.target = self.makeTarget()
262 self.owner = self.target.owner
263 self.target_url = api_url(self.target)
264 self.webservice = webservice_for_person(
265 self.owner, permission=OAuthPermission.WRITE_PRIVATE)
266
267 def test_getAccessTokens(self):
268 with person_logged_in(self.owner):
269 for description in ("Test token 1", "Test token 2"):
270 self.factory.makeAccessToken(
271 owner=self.owner, description=description,
272 target=self.target)
273 response = self.webservice.named_get(
274 self.target_url, "getAccessTokens", api_version="devel")
275 self.assertEqual(200, response.status)
276 self.assertContentEqual(
277 ["Test token 1", "Test token 2"],
278 [entry["description"] for entry in response.jsonBody()["entries"]])
279
280 def test_getAccessTokens_permissions(self):
281 webservice = webservice_for_person(None)
282 response = webservice.named_get(
283 self.target_url, "getAccessTokens", api_version="devel")
284 self.assertEqual(401, response.status)
285 self.assertIn(b"launchpad.Edit", response.body)
286
287 def test_getAccessTokens_query_count(self):
288 def get_tokens():
289 response = self.webservice.named_get(
290 self.target_url, "getAccessTokens", api_version="devel")
291 self.assertEqual(200, response.status)
292 self.assertIn(len(response.jsonBody()["entries"]), {0, 2, 4})
293
294 def create_token():
295 with person_logged_in(self.owner):
296 self.factory.makeAccessToken(
297 owner=self.owner, target=self.target)
298
299 get_tokens()
300 recorder1, recorder2 = record_two_runs(get_tokens, create_token, 2)
301 self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
302
303
304class TestAccessTokenTargetGitRepository(
305 TestAccessTokenTargetBase, TestCaseWithFactory):
306
307 def makeTarget(self):
308 return self.factory.makeGitRepository()
diff --git a/lib/lp/services/auth/webservice.py b/lib/lp/services/auth/webservice.py
222new file mode 100644309new file mode 100644
index 0000000..08e7ca1
--- /dev/null
+++ b/lib/lp/services/auth/webservice.py
@@ -0,0 +1,14 @@
1# Copyright 2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Personal access token webservice registrations."""
5
6__all__ = [
7 "IAccessToken",
8 "IAccessTokenTarget",
9 ]
10
11from lp.services.auth.interfaces import (
12 IAccessToken,
13 IAccessTokenTarget,
14 )
diff --git a/lib/lp/services/webapp/tests/test_servers.py b/lib/lp/services/webapp/tests/test_servers.py
index aa5123f..a5ffe0a 100644
--- a/lib/lp/services/webapp/tests/test_servers.py
+++ b/lib/lp/services/webapp/tests/test_servers.py
@@ -68,7 +68,6 @@ from lp.services.webapp.servers import (
68from lp.testing import (68from lp.testing import (
69 EventRecorder,69 EventRecorder,
70 logout,70 logout,
71 person_logged_in,
72 TestCase,71 TestCase,
73 TestCaseWithFactory,72 TestCaseWithFactory,
74 )73 )
@@ -845,9 +844,9 @@ class TestWebServiceAccessTokens(TestCaseWithFactory):
845844
846 def test_expired(self):845 def test_expired(self):
847 owner = self.factory.makePerson()846 owner = self.factory.makePerson()
848 secret, token = self.factory.makeAccessToken(owner=owner)847 secret, token = self.factory.makeAccessToken(
849 with person_logged_in(owner):848 owner=owner,
850 token.date_expires = datetime.now(pytz.UTC) - timedelta(days=1)849 date_expires=datetime.now(pytz.UTC) - timedelta(days=1))
851 transaction.commit()850 transaction.commit()
852851
853 request, publication = get_request_and_publication(852 request, publication = get_request_and_publication(
diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
index 96efcf6..dc13955 100644
--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
@@ -166,7 +166,8 @@
166 <xsl:with-param name="url">166 <xsl:with-param name="url">
167 <xsl:choose>167 <xsl:choose>
168 <xsl:when test="168 <xsl:when test="
169 @id = 'bug_link_target'169 @id = 'access_token_target'
170 or @id = 'bug_link_target'
170 or @id = 'bug_target'171 or @id = 'bug_target'
171 or @id = 'faq_target'172 or @id = 'faq_target'
172 or @id = 'git_target'173 or @id = 'git_target'
@@ -190,6 +191,10 @@
190 <xsl:template name="find-entry-uri">191 <xsl:template name="find-entry-uri">
191 <xsl:value-of select="$base"/>192 <xsl:value-of select="$base"/>
192 <xsl:choose>193 <xsl:choose>
194 <xsl:when test="@id = 'access_token'">
195 <xsl:text>/[target URL]/+access-token/</xsl:text>
196 <var>&lt;id&gt;</var>
197 </xsl:when>
193 <xsl:when test="@id = 'archive'">198 <xsl:when test="@id = 'archive'">
194 <xsl:text>/</xsl:text>199 <xsl:text>/</xsl:text>
195 <var>&lt;distribution&gt;</var>200 <var>&lt;distribution&gt;</var>
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 4fccdec..50f2ed4 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -4519,7 +4519,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
4519 return request_token.createAccessToken()4519 return request_token.createAccessToken()
45204520
4521 def makeAccessToken(self, secret=None, owner=None, description=None,4521 def makeAccessToken(self, secret=None, owner=None, description=None,
4522 target=None, scopes=None):4522 target=None, scopes=None, date_expires=None):
4523 """Create a personal access token.4523 """Create a personal access token.
45244524
4525 :return: A tuple of the secret for the new token and the token4525 :return: A tuple of the secret for the new token and the token
@@ -4536,7 +4536,8 @@ class BareLaunchpadObjectFactory(ObjectFactory):
4536 if scopes is None:4536 if scopes is None:
4537 scopes = []4537 scopes = []
4538 token = getUtility(IAccessTokenSet).new(4538 token = getUtility(IAccessTokenSet).new(
4539 secret, owner, description, target, scopes)4539 secret, owner, description, target, scopes,
4540 date_expires=date_expires)
4540 return secret, token4541 return secret, token
45414542
4542 def makeCVE(self, sequence, description=None,4543 def makeCVE(self, sequence, description=None,

Subscribers

People subscribed via source and target branches

to status/vote changes: