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 | 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 | # |
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 | 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"]) |
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 | ) |
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`.""" |
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 | 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.""" |
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 | |
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> |
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 | __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) |
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 | __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) |
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 | 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() |
703 | diff --git a/lib/lp/services/auth/webservice.py b/lib/lp/services/auth/webservice.py |
704 | 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 | +# 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 | + ) |
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 | 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( |
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 | <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><id></var> |
769 | + </xsl:when> |
770 | <xsl:when test="@id = 'archive'"> |
771 | <xsl:text>/</xsl:text> |
772 | <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 | 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, |
looks good!