Merge lp:~maxiberta/launchpad/named-auth-tokens-bulk-api into lp:launchpad

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
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 :

Updated branch with fixes and improvements based on code review.

Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

Also, added an optional `names` parameter to `Archive.getNamedAuthTokens()` to keep uniformity with `Archive.newNamedAuthTokens()` and `Archive.revokeNamedAuthTokens()`.

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):