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