Merge lp:~maxiberta/launchpad/named-auth-tokens-bulk-api into lp:launchpad
- named-auth-tokens-bulk-api
- Merge into devel
Proposed by
Maximiliano Bertacchini
Status: | Merged |
---|---|
Merged at revision: | 18138 |
Proposed branch: | lp:~maxiberta/launchpad/named-auth-tokens-bulk-api |
Merge into: | lp:launchpad |
Prerequisite: | lp:~maxiberta/launchpad/named-auth-tokens-htaccess |
Diff against target: |
396 lines (+210/-27) 5 files modified
lib/lp/soyuz/interfaces/archive.py (+57/-11) lib/lp/soyuz/interfaces/archiveauthtoken.py (+12/-2) lib/lp/soyuz/model/archive.py (+45/-5) lib/lp/soyuz/model/archiveauthtoken.py (+13/-4) lib/lp/soyuz/tests/test_archive.py (+83/-5) |
To merge this branch: | bzr merge lp:~maxiberta/launchpad/named-auth-tokens-bulk-api |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+300016@code.launchpad.net |
Commit message
Add API for bulk creation and revocation of named auth tokens.
Description of the change
Add API for bulk creation and revocation of named auth tokens.
To post a comment you must log in.
Revision history for this message
Maximiliano Bertacchini (maxiberta) : | # |
Revision history for this message
Colin Watson (cjwatson) : | # |
review:
Approve
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote : | # |
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote : | # |
Also, added an optional `names` parameter to `Archive.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/soyuz/interfaces/archive.py' |
2 | --- lib/lp/soyuz/interfaces/archive.py 2016-07-14 14:12:23 +0000 |
3 | +++ lib/lp/soyuz/interfaces/archive.py 2016-07-14 16:33:45 +0000 |
4 | @@ -2118,30 +2118,64 @@ |
5 | externally-usable archive URL including basic auth. |
6 | """ |
7 | |
8 | + @call_with(as_dict=True) |
9 | + @operation_parameters( |
10 | + names=List( |
11 | + title=_("Authorization token names"), |
12 | + value_type=TextLine(), required=True)) |
13 | + @export_write_operation() |
14 | + @operation_for_version("devel") |
15 | + def newNamedAuthTokens(names, as_dict=False): |
16 | + """Create named authorization tokens in bulk. |
17 | + |
18 | + :param names: A list of token names. |
19 | + :param as_dict: Optional boolean, controls whether the return value is |
20 | + a list of dictionaries or a list of full objects. |
21 | + |
22 | + :return: A list of `ArchiveAuthToken` objects or a dictionary of |
23 | + {name: {token, archive_url} where `name` is a token name, |
24 | + `token` is the secret and `archive_url` is the externally-usable |
25 | + archive URL including basic auth. |
26 | + """ |
27 | + |
28 | + @call_with(as_dict=True) |
29 | @operation_parameters( |
30 | name=TextLine(title=_("Authorization token name"), required=True)) |
31 | @export_read_operation() |
32 | @operation_for_version("devel") |
33 | - def getNamedAuthToken(name): |
34 | + def getNamedAuthToken(name, as_dict=False): |
35 | """Return a named authorization token for the given name in this |
36 | archive. |
37 | |
38 | :param name: The identifier string for a token. |
39 | + :param as_dict: Optional boolean, controls whether the return value is |
40 | + a dictionary or a full object. |
41 | |
42 | - :return: A dictionary where the value of `token` is the secret and |
43 | - the value of `archive_url` is the externally-usable archive URL |
44 | - including basic auth. |
45 | + :return: An `ArchiveAuthToken` object or a dictionary where the value |
46 | + of `token` is the secret and the value of `archive_url` is the |
47 | + externally-usable archive URL including basic auth. |
48 | :raises NotFoundError: if no matching token could be found. |
49 | """ |
50 | |
51 | + @call_with(as_dict=True) |
52 | + @operation_parameters( |
53 | + names=List( |
54 | + title=_("Authorization token names"), |
55 | + value_type=TextLine(), required=False)) |
56 | @export_read_operation() |
57 | @operation_for_version("devel") |
58 | - def getNamedAuthTokens(): |
59 | - """Return a list of named authorization tokens for this archive. |
60 | - |
61 | - :return: A list of dictionaries where the value of `token` is the |
62 | - secret and the value of `archive_url` is the externally-usable |
63 | - archive URL including basic auth. |
64 | + def getNamedAuthTokens(names=None, as_dict=False): |
65 | + """Return a subset of active named authorization tokens for this |
66 | + archive if `names` is specified, or all active named authorization |
67 | + tokens for this archive is `names` is null. |
68 | + |
69 | + :param names: An optional list of token names. |
70 | + :param as_dict: Optional boolean, controls whether the return value is |
71 | + a list of dictionares or a list of full objects. |
72 | + |
73 | + :return: A list of `ArchiveAuthToken` objects or a list of dictionaries |
74 | + where `token` is the secret and `archive_url` is the |
75 | + externally-usable archive URL including basic auth. |
76 | """ |
77 | |
78 | @operation_parameters( |
79 | @@ -2149,12 +2183,24 @@ |
80 | @export_write_operation() |
81 | @operation_for_version("devel") |
82 | def revokeNamedAuthToken(name): |
83 | - """Deactivates a named authorization token. |
84 | + """Deactivate a named authorization token. |
85 | |
86 | :param name: The identifier string for a token. |
87 | :raises NotFoundError: if no matching token could be found. |
88 | """ |
89 | |
90 | + @operation_parameters( |
91 | + names=List( |
92 | + title=_("Authorization token names"), |
93 | + value_type=TextLine(), required=True)) |
94 | + @export_write_operation() |
95 | + @operation_for_version("devel") |
96 | + def revokeNamedAuthTokens(names): |
97 | + """Deactivate named authorization tokens in bulk. |
98 | + |
99 | + :param names: A list of token names. |
100 | + """ |
101 | + |
102 | |
103 | class IArchiveAdmin(Interface): |
104 | """Archive interface for operations restricted by commercial.""" |
105 | |
106 | === modified file 'lib/lp/soyuz/interfaces/archiveauthtoken.py' |
107 | --- lib/lp/soyuz/interfaces/archiveauthtoken.py 2016-07-07 18:27:16 +0000 |
108 | +++ lib/lp/soyuz/interfaces/archiveauthtoken.py 2016-07-14 16:33:45 +0000 |
109 | @@ -119,9 +119,19 @@ |
110 | :return An object conforming to `IArchiveAuthToken` or None. |
111 | """ |
112 | |
113 | - def getActiveNamedTokensForArchive(archive): |
114 | - """Retrieve all active named tokens for the given archive. |
115 | + def getActiveNamedTokensForArchive(archive, names=None): |
116 | + """Retrieve a subset of active named tokens for the given archive if |
117 | + `names` is specified, or all active named tokens for the archive if |
118 | + `names` is null. |
119 | |
120 | :param archive: The archive to which the tokens correspond. |
121 | + :param names: An optional list of token names. |
122 | :return: A result set containing `IArchiveAuthToken`s. |
123 | """ |
124 | + |
125 | + def deactivateNamedTokensForArchive(archive, names): |
126 | + """Deactivate named tokens for the given archive. |
127 | + |
128 | + :param archive: The archive to which the tokens correspond. |
129 | + :param names: A list of token names. |
130 | + """ |
131 | |
132 | === modified file 'lib/lp/soyuz/model/archive.py' |
133 | --- lib/lp/soyuz/model/archive.py 2016-07-14 14:12:23 +0000 |
134 | +++ lib/lp/soyuz/model/archive.py 2016-07-14 16:33:45 +0000 |
135 | @@ -93,6 +93,7 @@ |
136 | from lp.registry.model.teammembership import TeamParticipation |
137 | from lp.services.config import config |
138 | from lp.services.database.bulk import ( |
139 | + create, |
140 | load_referencing, |
141 | load_related, |
142 | ) |
143 | @@ -1985,20 +1986,54 @@ |
144 | else: |
145 | return archive_auth_token |
146 | |
147 | - def getNamedAuthToken(self, name): |
148 | + def newNamedAuthTokens(self, names, as_dict=False): |
149 | + """See `IArchive`.""" |
150 | + |
151 | + if not getFeatureFlag(NAMED_AUTH_TOKEN_FEATURE_FLAG): |
152 | + raise NamedAuthTokenFeatureDisabled() |
153 | + |
154 | + # Bail if the archive isn't private |
155 | + if not self.private: |
156 | + raise ArchiveNotPrivate("Archive must be private.") |
157 | + |
158 | + # Check for duplicate names. |
159 | + token_set = getUtility(IArchiveAuthTokenSet) |
160 | + dup_tokens = token_set.getActiveNamedTokensForArchive(self, names) |
161 | + dup_names = set(token.name for token in dup_tokens) |
162 | + |
163 | + values = [ |
164 | + (name, create_token(20), self) for name in set(names) - dup_names] |
165 | + tokens = create( |
166 | + (ArchiveAuthToken.name, ArchiveAuthToken.token, |
167 | + ArchiveAuthToken.archive), values, get_objects=True) |
168 | + |
169 | + # Return all requested tokens, including duplicates. |
170 | + tokens.extend(dup_tokens) |
171 | + if as_dict: |
172 | + return {token.name: token.asDict() for token in tokens} |
173 | + else: |
174 | + return tokens |
175 | + |
176 | + def getNamedAuthToken(self, name, as_dict=False): |
177 | """See `IArchive`.""" |
178 | token_set = getUtility(IArchiveAuthTokenSet) |
179 | auth_token = token_set.getActiveNamedTokenForArchive(self, name) |
180 | if auth_token is not None: |
181 | - return auth_token.asDict() |
182 | + if as_dict: |
183 | + return auth_token.asDict() |
184 | + else: |
185 | + return auth_token |
186 | else: |
187 | raise NotFoundError(name) |
188 | |
189 | - def getNamedAuthTokens(self): |
190 | + def getNamedAuthTokens(self, names=None, as_dict=False): |
191 | """See `IArchive`.""" |
192 | token_set = getUtility(IArchiveAuthTokenSet) |
193 | - auth_tokens = token_set.getActiveNamedTokensForArchive(self) |
194 | - return [auth_token.asDict() for auth_token in auth_tokens] |
195 | + auth_tokens = token_set.getActiveNamedTokensForArchive(self, names) |
196 | + if as_dict: |
197 | + return [auth_token.asDict() for auth_token in auth_tokens] |
198 | + else: |
199 | + return auth_tokens |
200 | |
201 | def revokeNamedAuthToken(self, name): |
202 | """See `IArchive`.""" |
203 | @@ -2009,6 +2044,11 @@ |
204 | else: |
205 | raise NotFoundError(name) |
206 | |
207 | + def revokeNamedAuthTokens(self, names): |
208 | + """See `IArchive`.""" |
209 | + token_set = getUtility(IArchiveAuthTokenSet) |
210 | + token_set.deactivateNamedTokensForArchive(self, names) |
211 | + |
212 | def newSubscription(self, subscriber, registrant, date_expires=None, |
213 | description=None): |
214 | """See `IArchive`.""" |
215 | |
216 | === modified file 'lib/lp/soyuz/model/archiveauthtoken.py' |
217 | --- lib/lp/soyuz/model/archiveauthtoken.py 2016-07-07 18:27:16 +0000 |
218 | +++ lib/lp/soyuz/model/archiveauthtoken.py 2016-07-14 16:33:45 +0000 |
219 | @@ -103,7 +103,16 @@ |
220 | return self.getByArchive(archive).find( |
221 | ArchiveAuthToken.name == name).one() |
222 | |
223 | - def getActiveNamedTokensForArchive(self, archive): |
224 | - """See `IArchiveAuthTokenSet`.""" |
225 | - return self.getByArchive(archive).find( |
226 | - ArchiveAuthToken.name != None) |
227 | + def getActiveNamedTokensForArchive(self, archive, names=None): |
228 | + """See `IArchiveAuthTokenSet`.""" |
229 | + if names: |
230 | + return self.getByArchive(archive).find( |
231 | + ArchiveAuthToken.name.is_in(names)) |
232 | + else: |
233 | + return self.getByArchive(archive).find( |
234 | + ArchiveAuthToken.name != None) |
235 | + |
236 | + def deactivateNamedTokensForArchive(self, archive, names): |
237 | + """See `IArchiveAuthTokenSet`.""" |
238 | + tokens = self.getActiveNamedTokensForArchive(archive, names) |
239 | + tokens.set(date_deactivated=UTC_NOW) |
240 | |
241 | === modified file 'lib/lp/soyuz/tests/test_archive.py' |
242 | --- lib/lp/soyuz/tests/test_archive.py 2016-07-14 14:12:23 +0000 |
243 | +++ lib/lp/soyuz/tests/test_archive.py 2016-07-14 16:33:45 +0000 |
244 | @@ -12,8 +12,10 @@ |
245 | |
246 | from pytz import UTC |
247 | from testtools.matchers import ( |
248 | + AllMatch, |
249 | DocTestMatches, |
250 | LessThan, |
251 | + MatchesPredicate, |
252 | MatchesRegex, |
253 | MatchesStructure, |
254 | ) |
255 | @@ -41,6 +43,7 @@ |
256 | from lp.registry.interfaces.teammembership import TeamMembershipStatus |
257 | from lp.services.database.interfaces import IStore |
258 | from lp.services.database.sqlbase import sqlvalues |
259 | +from lp.services.features import getFeatureFlag |
260 | from lp.services.features.testing import FeatureFixture |
261 | from lp.services.job.interfaces.job import JobStatus |
262 | from lp.services.propertycache import ( |
263 | @@ -85,7 +88,6 @@ |
264 | RedirectedPocket, |
265 | VersionRequiresName, |
266 | ) |
267 | -from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthTokenSet |
268 | from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet |
269 | from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus |
270 | from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet |
271 | @@ -111,6 +113,7 @@ |
272 | login, |
273 | login_person, |
274 | person_logged_in, |
275 | + StormStatementRecorder, |
276 | RequestTimelineCollector, |
277 | TestCaseWithFactory, |
278 | ) |
279 | @@ -1295,8 +1298,7 @@ |
280 | |
281 | def test_newNamedAuthToken_private_archive(self): |
282 | res = self.private_ppa.newNamedAuthToken(u"tokenname", as_dict=True) |
283 | - token = getUtility(IArchiveAuthTokenSet).getActiveNamedTokenForArchive( |
284 | - self.private_ppa, u"tokenname") |
285 | + token = self.private_ppa.getNamedAuthToken(u"tokenname") |
286 | self.assertIsNotNone(token) |
287 | self.assertIsNone(token.person) |
288 | self.assertEqual("tokenname", token.name) |
289 | @@ -1323,6 +1325,39 @@ |
290 | token = self.private_ppa.newNamedAuthToken(u"tokenname", u"secret") |
291 | self.assertEqual(u"secret", token.token) |
292 | |
293 | + def test_newNamedAuthTokens_private_archive(self): |
294 | + res = self.private_ppa.newNamedAuthTokens( |
295 | + (u"name1", u"name2"), as_dict=True) |
296 | + tokens = self.private_ppa.getNamedAuthTokens() |
297 | + self.assertDictEqual({tok.name: tok.asDict() for tok in tokens}, res) |
298 | + |
299 | + def test_newNamedAuthTokens_public_archive(self): |
300 | + public_ppa = self.factory.makeArchive(private=False) |
301 | + self.assertRaises(ArchiveNotPrivate, |
302 | + public_ppa.newNamedAuthTokens, (u"name1", u"name2")) |
303 | + |
304 | + def test_newNamedAuthTokens_duplicate_name(self): |
305 | + self.private_ppa.newNamedAuthToken(u"tok1") |
306 | + res = self.private_ppa.newNamedAuthTokens( |
307 | + (u"tok1", u"tok2", u"tok3"), as_dict=True) |
308 | + tokens = self.private_ppa.getNamedAuthTokens() |
309 | + self.assertDictEqual({tok.name: tok.asDict() for tok in tokens}, res) |
310 | + |
311 | + def test_newNamedAuthTokens_idempotent(self): |
312 | + names = (u"name1", u"name2", u"name3", u"name4", u"name5") |
313 | + res1 = self.private_ppa.newNamedAuthTokens(names, as_dict=True) |
314 | + res2 = self.private_ppa.newNamedAuthTokens(names, as_dict=True) |
315 | + self.assertEqual(res1, res2) |
316 | + |
317 | + def test_newNamedAuthTokens_query_count(self): |
318 | + # Preload feature flag so it is cached. |
319 | + getFeatureFlag(NAMED_AUTH_TOKEN_FEATURE_FLAG) |
320 | + with StormStatementRecorder() as recorder1: |
321 | + self.private_ppa.newNamedAuthTokens((u"tok1")) |
322 | + with StormStatementRecorder() as recorder2: |
323 | + self.private_ppa.newNamedAuthTokens((u"tok1", u"tok2", u"tok3")) |
324 | + self.assertThat(recorder2, HasQueryCount.byEquality(recorder1)) |
325 | + |
326 | def test_getNamedAuthToken_with_no_token(self): |
327 | self.assertRaises( |
328 | NotFoundError, self.private_ppa.getNamedAuthToken, u"tokenname") |
329 | @@ -1330,7 +1365,7 @@ |
330 | def test_getNamedAuthToken_with_token(self): |
331 | res = self.private_ppa.newNamedAuthToken(u"tokenname", as_dict=True) |
332 | self.assertEqual( |
333 | - self.private_ppa.getNamedAuthToken(u"tokenname"), |
334 | + self.private_ppa.getNamedAuthToken(u"tokenname", as_dict=True), |
335 | res) |
336 | |
337 | def test_revokeNamedAuthToken_with_token(self): |
338 | @@ -1342,6 +1377,40 @@ |
339 | self.assertRaises( |
340 | NotFoundError, self.private_ppa.revokeNamedAuthToken, u"tokenname") |
341 | |
342 | + def test_revokeNamedAuthTokens(self): |
343 | + names = (u"name1", u"name2", u"name3", u"name4", u"name5") |
344 | + tokens = self.private_ppa.newNamedAuthTokens(names) |
345 | + self.assertThat( |
346 | + tokens, AllMatch(MatchesPredicate( |
347 | + lambda x: not x.date_deactivated, '%s is not active.'))) |
348 | + self.private_ppa.revokeNamedAuthTokens(names) |
349 | + self.assertThat( |
350 | + tokens, AllMatch(MatchesPredicate( |
351 | + lambda x: x.date_deactivated, '%s is active.'))) |
352 | + |
353 | + def test_revokeNamedAuthTokens_with_previously_revoked_token(self): |
354 | + names = (u"name1", u"name2", u"name3", u"name4", u"name5") |
355 | + self.private_ppa.newNamedAuthTokens(names) |
356 | + token1 = self.private_ppa.getNamedAuthToken(u"name1") |
357 | + token2 = self.private_ppa.getNamedAuthToken(u"name2") |
358 | + |
359 | + # Revoke token1. |
360 | + deactivation_time_1 = datetime.now(UTC) - timedelta(seconds=90) |
361 | + token1.date_deactivated = deactivation_time_1 |
362 | + |
363 | + # Revoke all tokens, including token1. |
364 | + self.private_ppa.revokeNamedAuthTokens(names) |
365 | + |
366 | + # Check that token1.date_deactivated has not changed. |
367 | + self.assertEqual(deactivation_time_1, token1.date_deactivated) |
368 | + self.assertLess(token1.date_deactivated, token2.date_deactivated) |
369 | + |
370 | + def test_revokeNamedAuthTokens_idempotent(self): |
371 | + names = (u"name1", u"name2", u"name3", u"name4", u"name5") |
372 | + res1 = self.private_ppa.revokeNamedAuthTokens(names) |
373 | + res2 = self.private_ppa.revokeNamedAuthTokens(names) |
374 | + self.assertEqual(res1, res2) |
375 | + |
376 | def test_getNamedAuthToken_with_revoked_token(self): |
377 | self.private_ppa.newNamedAuthToken(u"tokenname") |
378 | self.private_ppa.revokeNamedAuthToken(u"tokenname") |
379 | @@ -1355,7 +1424,16 @@ |
380 | self.private_ppa.revokeNamedAuthToken(u"tokenname3") |
381 | self.assertContentEqual( |
382 | [res1, res2], |
383 | - self.private_ppa.getNamedAuthTokens()) |
384 | + self.private_ppa.getNamedAuthTokens(as_dict=True)) |
385 | + |
386 | + def test_getNamedAuthTokens_with_names(self): |
387 | + res1 = self.private_ppa.newNamedAuthToken(u"tokenname1", as_dict=True) |
388 | + res2 = self.private_ppa.newNamedAuthToken(u"tokenname2", as_dict=True) |
389 | + self.private_ppa.newNamedAuthToken(u"tokenname3") |
390 | + self.assertContentEqual( |
391 | + [res1, res2], |
392 | + self.private_ppa.getNamedAuthTokens( |
393 | + (u"tokenname1", u"tokenname2"), as_dict=True)) |
394 | |
395 | |
396 | class TestGetBinaryPackageRelease(TestCaseWithFactory): |
Updated branch with fixes and improvements based on code review.