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

Proposed by Maximiliano Bertacchini
Status: Merged
Merged at revision: 18137
Proposed branch: lp:~maxiberta/launchpad/named-auth-tokens-htaccess
Merge into: lp:launchpad
Prerequisite: lp:~maxiberta/launchpad/named-auth-tokens
Diff against target: 465 lines (+174/-55)
7 files modified
lib/lp/archivepublisher/htaccess.py (+27/-19)
lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py (+32/-3)
lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py (+77/-7)
lib/lp/archivepublisher/tests/test_htaccess.py (+19/-9)
lib/lp/soyuz/interfaces/archive.py (+7/-4)
lib/lp/soyuz/model/archive.py (+5/-2)
lib/lp/soyuz/tests/test_archive.py (+7/-11)
To merge this branch: bzr merge lp:~maxiberta/launchpad/named-auth-tokens-htaccess
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Maximiliano Bertacchini Pending
Review via email: mp+300002@code.launchpad.net

This proposal supersedes a proposal from 2016-07-13.

Commit message

Add support for named auth tokens in ppa htpasswd creation.

Description of the change

Add support for named auth tokens in ppa htpasswd creation (including the removal of revoked named tokens).

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Maximiliano Bertacchini (maxiberta) :
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

Updated branch with fixes and improvements based on code review.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/archivepublisher/htaccess.py'
2--- lib/lp/archivepublisher/htaccess.py 2013-06-20 05:50:00 +0000
3+++ lib/lp/archivepublisher/htaccess.py 2016-07-14 16:06:58 +0000
4@@ -58,43 +58,51 @@
5
6 file = open(filename, "a")
7 try:
8- for entry in users:
9- user, password, salt = entry
10+ for user, password, salt in users:
11 encrypted = crypt.crypt(password, salt)
12 file.write("%s:%s\n" % (user, encrypted))
13 finally:
14 file.close()
15
16
17-def htpasswd_credentials_for_archive(archive, tokens=None):
18+def htpasswd_credentials_for_archive(archive):
19 """Return credentials for an archive for use with write_htpasswd.
20
21 :param archive: An `IArchive` (must be private)
22- :param tokens: Optional iterable of `IArchiveAuthToken`s.
23 :return: Iterable of tuples with (user, password, salt) for use with
24 write_htpasswd.
25 """
26 assert archive.private, "Archive %r must be private" % archive
27
28- if tokens is None:
29- tokens = IStore(ArchiveAuthToken).find(
30- (ArchiveAuthToken.person_id, ArchiveAuthToken.token),
31- ArchiveAuthToken.archive == archive,
32- ArchiveAuthToken.date_deactivated == None)
33+ tokens = IStore(ArchiveAuthToken).find(
34+ (ArchiveAuthToken.person_id, ArchiveAuthToken.name,
35+ ArchiveAuthToken.token),
36+ ArchiveAuthToken.archive == archive,
37+ ArchiveAuthToken.date_deactivated == None)
38 # We iterate tokens more than once - materialise it.
39 tokens = list(tokens)
40
41- # The first .htpasswd entry is the buildd_secret.
42- yield (BUILDD_USER_NAME, archive.buildd_secret, BUILDD_USER_NAME[:2])
43-
44+ # Preload map with person ID to person name.
45 person_ids = map(itemgetter(0), tokens)
46 names = dict(
47 IStore(Person).find(
48 (Person.id, Person.name), Person.id.is_in(set(person_ids))))
49- # Combine the token list with the person list, sorting by person ID
50- # so the file can be compared later.
51- sorted_tokens = [(token[1], names[token[0]]) for token in sorted(tokens)]
52- # Iterate over tokens and write the appropriate htpasswd
53- # entries for them.
54- for token, person_name in sorted_tokens:
55- yield (person_name, token, person_name[:2])
56+
57+ # Format the user field by combining the token list with the person list
58+ # (when token has person_id) or prepending a '+' (for named tokens).
59+ output = []
60+ for person_id, token_name, token in tokens:
61+ if token_name:
62+ # A named auth token.
63+ output.append(('+' + token_name, token, token_name[:2]))
64+ else:
65+ # A subscription auth token.
66+ output.append((names[person_id], token, names[person_id][:2]))
67+
68+ # The first .htpasswd entry is the buildd_secret.
69+ yield (BUILDD_USER_NAME, archive.buildd_secret, BUILDD_USER_NAME[:2])
70+
71+ # Iterate over tokens and write the appropriate htpasswd entries for them.
72+ # Sort by name/person ID so the file can be compared later.
73+ for user, password, salt in sorted(output):
74+ yield (user, password, salt)
75
76=== modified file 'lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py'
77--- lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py 2014-10-29 06:04:09 +0000
78+++ lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py 2016-07-14 16:06:58 +0000
79@@ -89,8 +89,7 @@
80 fd, temp_filename = tempfile.mkstemp(dir=pub_config.temproot)
81 os.close(fd)
82
83- write_htpasswd(
84- temp_filename, htpasswd_credentials_for_archive(ppa))
85+ write_htpasswd(temp_filename, htpasswd_credentials_for_archive(ppa))
86
87 return temp_filename
88
89@@ -176,6 +175,7 @@
90 store = IStore(ArchiveSubscriber)
91 valid_tokens = store.find(
92 ArchiveAuthToken,
93+ ArchiveAuthToken.name == None,
94 ArchiveAuthToken.date_deactivated == None,
95 ArchiveAuthToken.archive_id == ArchiveSubscriber.archive_id,
96 ArchiveSubscriber.status == ArchiveSubscriberStatus.CURRENT,
97@@ -186,6 +186,7 @@
98 # all active tokens and valid tokens.
99 all_active_tokens = store.find(
100 ArchiveAuthToken,
101+ ArchiveAuthToken.name == None,
102 ArchiveAuthToken.date_deactivated == None)
103
104 return all_active_tokens.difference(valid_tokens)
105@@ -274,6 +275,22 @@
106 *extra_expr)
107 return new_ppa_tokens
108
109+ def getDeactivatedNamedTokens(self, since=None):
110+ """Return result set of named tokens deactivated since given time."""
111+ now = datetime.now(pytz.UTC)
112+
113+ store = IStore(ArchiveAuthToken)
114+ extra_expr = []
115+ if since:
116+ extra_expr = [ArchiveAuthToken.date_deactivated >= since]
117+ tokens = store.find(
118+ ArchiveAuthToken,
119+ ArchiveAuthToken.name != None,
120+ ArchiveAuthToken.date_deactivated != None,
121+ ArchiveAuthToken.date_deactivated <= now,
122+ *extra_expr)
123+ return tokens
124+
125 def getNewPrivatePPAs(self, since=None):
126 """Return the recently created private PPAs."""
127 store = IStore(Archive)
128@@ -294,6 +311,18 @@
129
130 last_success = self.getTimeToSyncFrom()
131
132+ # Include ppas with named tokens deactivated since last time we ran.
133+ num_tokens = 0
134+ for token in self.getDeactivatedNamedTokens(since=last_success):
135+ affected_ppas.add(token.archive)
136+ num_tokens += 1
137+
138+ new_ppa_count = len(affected_ppas)
139+ self.logger.debug(
140+ "%s deactivated named tokens since last run, %s PPAs affected"
141+ % (num_tokens, new_ppa_count - current_ppa_count))
142+ current_ppa_count = new_ppa_count
143+
144 # In addition to the ppas that are affected by deactivated
145 # tokens, we also want to include any ppas that have tokens
146 # created since the last time we ran.
147@@ -316,7 +345,7 @@
148
149 self.logger.debug('%s PPAs require updating' % new_ppa_count)
150 for ppa in affected_ppas:
151- # If this PPA is blacklisted, do not touch it's htaccess/pwd
152+ # If this PPA is blacklisted, do not touch its htaccess/pwd
153 # files.
154 blacklisted_ppa_names_for_owner = self.blacklist.get(
155 ppa.owner.name, [])
156
157=== modified file 'lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py'
158--- lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py 2016-01-26 15:47:37 +0000
159+++ lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py 2016-07-14 16:06:58 +0000
160@@ -32,6 +32,7 @@
161 from lp.registry.interfaces.person import IPersonSet
162 from lp.registry.interfaces.teammembership import TeamMembershipStatus
163 from lp.services.config import config
164+from lp.services.features.testing import FeatureFixture
165 from lp.services.log.logger import BufferLogger
166 from lp.services.osutils import (
167 ensure_directory_exists,
168@@ -43,6 +44,7 @@
169 ArchiveStatus,
170 ArchiveSubscriberStatus,
171 )
172+from lp.soyuz.interfaces.archive import NAMED_AUTH_TOKEN_FEATURE_FLAG
173 from lp.testing import TestCaseWithFactory
174 from lp.testing.dbuser import (
175 lp_dbuser,
176@@ -72,6 +74,9 @@
177 ubuntutest = getUtility(IDistributionSet)['ubuntutest']
178 self.ppa.distribution = ubuntutest
179
180+ # Enable named auth tokens.
181+ self.useFixture(FeatureFixture({NAMED_AUTH_TOKEN_FEATURE_FLAG: u"on"}))
182+
183 def getScript(self, test_args=None):
184 """Return a HtaccessTokenGenerator instance."""
185 if test_args is None:
186@@ -260,6 +265,11 @@
187 all_persons, persons1, persons2, tokens) = data
188 team1_person = persons1[0]
189
190+ # Named tokens should be ignored for deactivation.
191+ self.ppa.newNamedAuthToken(u"tokenname1")
192+ named_token = self.ppa.newNamedAuthToken(u"tokenname2")
193+ named_token.deactivate()
194+
195 # Initially, nothing is eligible for deactivation.
196 script = self.getScript()
197 script.deactivateInvalidTokens()
198@@ -325,12 +335,9 @@
199 sub2 = self.ppa.newSubscription(name16, self.ppa.owner)
200 token1 = self.ppa.newAuthToken(name12)
201 token2 = self.ppa.newAuthToken(name16)
202+ token3 = self.ppa.newNamedAuthToken(u"tokenname3")
203 self.layer.txn.commit()
204- subs = [sub1]
205- subs.append(sub2)
206- tokens = [token1]
207- tokens.append(token2)
208- return subs, tokens
209+ return (sub1, sub2), (token1, token2, token3)
210
211 def ensureNoFiles(self):
212 """Ensure the .ht* files don't already exist."""
213@@ -375,6 +382,36 @@
214 os.remove(htaccess)
215 os.remove(htpasswd)
216
217+ def testBasicOperation_with_named_tokens(self):
218+ """Invoke the actual script and make sure it generates some files."""
219+ token1 = self.ppa.newNamedAuthToken(u"tokenname1")
220+ token2 = self.ppa.newNamedAuthToken(u"tokenname2")
221+ token3 = self.ppa.newNamedAuthToken(u"tokenname3")
222+ token3.deactivate()
223+
224+ # Call the script and check that we have a .htaccess and a .htpasswd.
225+ htaccess, htpasswd = self.ensureNoFiles()
226+ script = self.getScript()
227+ script.main()
228+ self.assertThat([htaccess, htpasswd], AllMatch(FileExists()))
229+ with open(htpasswd) as htpasswd_file:
230+ contents = htpasswd_file.read()
231+ self.assertIn('+' + token1.name, contents)
232+ self.assertIn('+' + token2.name, contents)
233+ self.assertNotIn('+' + token3.name, contents)
234+
235+ # Deactivate a named token and verify it is removed from .htpasswd.
236+ token2.deactivate()
237+ script.main()
238+ self.assertThat([htaccess, htpasswd], AllMatch(FileExists()))
239+ with open(htpasswd) as htpasswd_file:
240+ contents = htpasswd_file.read()
241+ self.assertIn('+' + token1.name, contents)
242+ self.assertNotIn('+' + token2.name, contents)
243+ self.assertNotIn('+' + token3.name, contents)
244+ os.remove(htaccess)
245+ os.remove(htpasswd)
246+
247 def _setupOptionsData(self):
248 """Setup test data for option testing."""
249 subs, tokens = self.setupDummyTokens()
250@@ -598,14 +635,47 @@
251 script = self.getScript()
252 self.assertContentEqual(tokens[1:], script.getNewTokens())
253
254+ def test_getDeactivatedNamedTokens_no_previous_run(self):
255+ """All deactivated named tokens returned if there is no record
256+ of previous run."""
257+ last_start = datetime.now(pytz.UTC) - timedelta(seconds=90)
258+ before_last_start = last_start - timedelta(seconds=30)
259+
260+ self.ppa.newNamedAuthToken(u"tokenname1")
261+ token2 = self.ppa.newNamedAuthToken(u"tokenname2")
262+ token2.deactivate()
263+ token3 = self.ppa.newNamedAuthToken(u"tokenname3")
264+ token3.date_deactivated = before_last_start
265+
266+ script = self.getScript()
267+ self.assertContentEqual(
268+ [token2, token3], script.getDeactivatedNamedTokens())
269+
270+ def test_getDeactivatedNamedTokens_only_those_since_last_run(self):
271+ """Only named tokens deactivated since last run are returned."""
272+ last_start = datetime.now(pytz.UTC) - timedelta(seconds=90)
273+ before_last_start = last_start - timedelta(seconds=30)
274+ tomorrow = datetime.now(pytz.UTC) + timedelta(days=1)
275+
276+ self.ppa.newNamedAuthToken(u"tokenname1")
277+ token2 = self.ppa.newNamedAuthToken(u"tokenname2")
278+ token2.deactivate()
279+ token3 = self.ppa.newNamedAuthToken(u"tokenname3")
280+ token3.date_deactivated = before_last_start
281+ token4 = self.ppa.newNamedAuthToken(u"tokenname4")
282+ token4.date_deactivated = tomorrow
283+
284+ script = self.getScript()
285+ self.assertContentEqual(
286+ [token2], script.getDeactivatedNamedTokens(last_start))
287+
288 def test_processes_PPAs_without_subscription(self):
289 # A .htaccess file is written for Private PPAs even if they don't have
290 # any subscriptions.
291 htaccess, htpasswd = self.ensureNoFiles()
292 transaction.commit()
293
294- # Call the script and check that we have a .htaccess and a
295- # .htpasswd.
296+ # Call the script and check that we have a .htaccess and a .htpasswd.
297 return_code, stdout, stderr = self.runScript()
298 self.assertEqual(
299 return_code, 0, "Got a bad return code of %s\nOutput:\n%s" %
300
301=== modified file 'lib/lp/archivepublisher/tests/test_htaccess.py'
302--- lib/lp/archivepublisher/tests/test_htaccess.py 2012-05-31 01:57:07 +0000
303+++ lib/lp/archivepublisher/tests/test_htaccess.py 2016-07-14 16:06:58 +0000
304@@ -15,6 +15,8 @@
305 )
306 from lp.registry.interfaces.distribution import IDistributionSet
307 from lp.registry.interfaces.person import IPersonSet
308+from lp.services.features.testing import FeatureFixture
309+from lp.soyuz.interfaces.archive import NAMED_AUTH_TOKEN_FEATURE_FLAG
310 from lp.testing import TestCaseWithFactory
311 from lp.testing.layers import LaunchpadZopelessLayer
312
313@@ -36,6 +38,9 @@
314 ubuntutest = getUtility(IDistributionSet)['ubuntutest']
315 self.ppa.distribution = ubuntutest
316
317+ # Enable named auth tokens.
318+ self.useFixture(FeatureFixture({NAMED_AUTH_TOKEN_FEATURE_FLAG: u"on"}))
319+
320 def test_write_htpasswd(self):
321 """Test that writing the .htpasswd file works properly."""
322 fd, filename = tempfile.mkstemp()
323@@ -109,15 +114,20 @@
324 self.ppa.newSubscription(name12, self.ppa.owner)
325 self.ppa.newSubscription(name16, self.ppa.owner)
326 first_created_token = self.ppa.newAuthToken(name16)
327- tokens = [
328- (token.person_id, token.token)
329- for token in [self.ppa.newAuthToken(name12), first_created_token]]
330-
331- credentials = list(htpasswd_credentials_for_archive(self.ppa, tokens))
332-
333- self.assertContentEqual(
334+ second_created_token = self.ppa.newAuthToken(name12)
335+ named_token_20 = self.ppa.newNamedAuthToken(u"name20", as_dict=False)
336+ named_token_14 = self.ppa.newNamedAuthToken(u"name14", as_dict=False)
337+ named_token_99 = self.ppa.newNamedAuthToken(u"name99", as_dict=False)
338+ named_token_99.deactivate()
339+
340+ credentials = list(htpasswd_credentials_for_archive(self.ppa))
341+
342+ # Use assertEqual instead of assertContentEqual to verify order.
343+ self.assertEqual(
344 credentials, [
345 ("buildd", "geheim", "bu"),
346- ("name12", tokens[0][1], "na"),
347- ("name16", tokens[1][1], "na")
348+ ("+name14", named_token_14.token, "na"),
349+ ("+name20", named_token_20.token, "na"),
350+ ("name12", second_created_token.token, "na"),
351+ ("name16", first_created_token.token, "na"),
352 ])
353
354=== modified file 'lib/lp/soyuz/interfaces/archive.py'
355--- lib/lp/soyuz/interfaces/archive.py 2016-07-07 21:32:09 +0000
356+++ lib/lp/soyuz/interfaces/archive.py 2016-07-14 16:06:58 +0000
357@@ -2097,22 +2097,25 @@
358 :param dependency: is an `IArchive` object.
359 """
360
361+ @call_with(as_dict=True)
362 @operation_parameters(
363 name=TextLine(title=_("Authorization token name"), required=True),
364 token=TextLine(
365 title=_("Optional secret for this named token"), required=False))
366 @export_write_operation()
367 @operation_for_version("devel")
368- def newNamedAuthToken(name, token=None):
369+ def newNamedAuthToken(name, token=None, as_dict=False):
370 """Create a new named authorization token.
371
372 :param name: An identifier string for this token.
373 :param token: Optional unicode text to use as the token. One will be
374 generated if not given.
375+ :param as_dict: Optional boolean, controls whether the return value is
376+ a dictionary or a full object.
377
378- :return: A dictionary where the value of `token` is the secret and
379- the value of `archive_url` is the externally-usable archive URL
380- including basic auth.
381+ :return: An `ArchiveAuthToken` object or a dictionary where the value
382+ of `token` is the secret and the value of `archive_url` is the
383+ externally-usable archive URL including basic auth.
384 """
385
386 @operation_parameters(
387
388=== modified file 'lib/lp/soyuz/model/archive.py'
389--- lib/lp/soyuz/model/archive.py 2016-07-07 21:32:09 +0000
390+++ lib/lp/soyuz/model/archive.py 2016-07-14 16:06:58 +0000
391@@ -1952,7 +1952,7 @@
392 IStore(ArchiveAuthToken).add(archive_auth_token)
393 return archive_auth_token
394
395- def newNamedAuthToken(self, name, token=None):
396+ def newNamedAuthToken(self, name, token=None, as_dict=False):
397 """See `IArchive`."""
398
399 if not getFeatureFlag(NAMED_AUTH_TOKEN_FEATURE_FLAG):
400@@ -1980,7 +1980,10 @@
401 archive_auth_token.name = name
402 archive_auth_token.token = token
403 IStore(ArchiveAuthToken).add(archive_auth_token)
404- return archive_auth_token.asDict()
405+ if as_dict:
406+ return archive_auth_token.asDict()
407+ else:
408+ return archive_auth_token
409
410 def getNamedAuthToken(self, name):
411 """See `IArchive`."""
412
413=== modified file 'lib/lp/soyuz/tests/test_archive.py'
414--- lib/lp/soyuz/tests/test_archive.py 2016-07-07 21:32:09 +0000
415+++ lib/lp/soyuz/tests/test_archive.py 2016-07-14 16:06:58 +0000
416@@ -1294,7 +1294,7 @@
417 self.assertEqual(token.archive_url, url)
418
419 def test_newNamedAuthToken_private_archive(self):
420- res = self.private_ppa.newNamedAuthToken(u"tokenname")
421+ res = self.private_ppa.newNamedAuthToken(u"tokenname", as_dict=True)
422 token = getUtility(IArchiveAuthTokenSet).getActiveNamedTokenForArchive(
423 self.private_ppa, u"tokenname")
424 self.assertIsNotNone(token)
425@@ -1320,25 +1320,21 @@
426 self.private_ppa.newNamedAuthToken, u"tokenname")
427
428 def test_newNamedAuthToken_with_custom_secret(self):
429- self.private_ppa.newNamedAuthToken(u"tokenname", u"somesecret")
430- token = getUtility(IArchiveAuthTokenSet).getActiveNamedTokenForArchive(
431- self.private_ppa, u"tokenname")
432- self.assertEqual(u"somesecret", token.token)
433+ token = self.private_ppa.newNamedAuthToken(u"tokenname", u"secret")
434+ self.assertEqual(u"secret", token.token)
435
436 def test_getNamedAuthToken_with_no_token(self):
437 self.assertRaises(
438 NotFoundError, self.private_ppa.getNamedAuthToken, u"tokenname")
439
440 def test_getNamedAuthToken_with_token(self):
441- res = self.private_ppa.newNamedAuthToken(u"tokenname")
442+ res = self.private_ppa.newNamedAuthToken(u"tokenname", as_dict=True)
443 self.assertEqual(
444 self.private_ppa.getNamedAuthToken(u"tokenname"),
445 res)
446
447 def test_revokeNamedAuthToken_with_token(self):
448- self.private_ppa.newNamedAuthToken(u"tokenname")
449- token = getUtility(IArchiveAuthTokenSet).getActiveNamedTokenForArchive(
450- self.private_ppa, u"tokenname")
451+ token = self.private_ppa.newNamedAuthToken(u"tokenname")
452 self.private_ppa.revokeNamedAuthToken(u"tokenname")
453 self.assertIsNotNone(token.date_deactivated)
454
455@@ -1353,8 +1349,8 @@
456 NotFoundError, self.private_ppa.getNamedAuthToken, u"tokenname")
457
458 def test_getNamedAuthTokens(self):
459- res1 = self.private_ppa.newNamedAuthToken(u"tokenname1")
460- res2 = self.private_ppa.newNamedAuthToken(u"tokenname2")
461+ res1 = self.private_ppa.newNamedAuthToken(u"tokenname1", as_dict=True)
462+ res2 = self.private_ppa.newNamedAuthToken(u"tokenname2", as_dict=True)
463 self.private_ppa.newNamedAuthToken(u"tokenname3")
464 self.private_ppa.revokeNamedAuthToken(u"tokenname3")
465 self.assertContentEqual(