← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:ppa-wsgi-auth-logging into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:ppa-wsgi-auth-logging into launchpad:master.

Commit message:
Add more logging to PPA authorization

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/404012
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:ppa-wsgi-auth-logging into launchpad:master.
diff --git a/lib/lp/soyuz/wsgi/archiveauth.py b/lib/lp/soyuz/wsgi/archiveauth.py
index 94f9f59..6da96e0 100644
--- a/lib/lp/soyuz/wsgi/archiveauth.py
+++ b/lib/lp/soyuz/wsgi/archiveauth.py
@@ -16,6 +16,7 @@ __all__ = [
 import crypt
 from random import SystemRandom
 import string
+import sys
 import time
 
 import six
@@ -28,6 +29,17 @@ from lp.services.config import config
 from lp.services.memcache.client import memcache_client_factory
 
 
+def _log(environ, message, *args):
+    """Log a message to the WSGI error stream."""
+    # Ideally we might set up a proper logger instead, but that's more
+    # effort than is justified by something this small.
+    error_stream = environ.get("wsgi.errors", sys.stderr)
+    if args:
+        message = message % args
+    error_stream.write(message + "\n")
+    error_stream.flush()
+
+
 def _get_archive_reference(environ):
     # Reconstruct the relevant part of the URL.  We don't care about where
     # we're installed.
@@ -39,6 +51,8 @@ def _get_archive_reference(environ):
     path_parts = path.lstrip("/").split("/")
     if len(path_parts) >= 3:
         return "~%s/%s/%s" % (path_parts[0], path_parts[2], path_parts[1])
+    else:
+        _log(environ, "No archive reference found in URL '%s'.", path)
 
 
 _sr = SystemRandom()
@@ -74,6 +88,7 @@ def check_password(environ, user, password):
     crypted_password = _memcache_client.get(memcache_key)
     if (crypted_password and
             crypt.crypt(password, crypted_password) == crypted_password):
+        _log(environ, "%s@%s: Authorized (cached).", user, archive_reference)
         return True
     proxy = ServerProxy(config.personalpackagearchive.archive_api_endpoint)
     try:
@@ -81,10 +96,15 @@ def check_password(environ, user, password):
         # Cache positive responses for a minute to reduce database load.
         _memcache_client.set(
             memcache_key, _crypt_sha256(password), time.time() + 60)
+        _log(environ, "%s@%s: Authorized.", user, archive_reference)
         return True
     except Fault as e:
         if e.faultCode == 410:  # Unauthorized
+            _log(
+                environ, "%s@%s: Password does not match.",
+                user, archive_reference)
             return False
         else:
             # Interpret any other fault as NotFound (320).
+            _log(environ, e.faultString)
             return None
diff --git a/lib/lp/soyuz/wsgi/tests/test_archiveauth.py b/lib/lp/soyuz/wsgi/tests/test_archiveauth.py
index d33cb34..6749548 100644
--- a/lib/lp/soyuz/wsgi/tests/test_archiveauth.py
+++ b/lib/lp/soyuz/wsgi/tests/test_archiveauth.py
@@ -13,6 +13,7 @@ import subprocess
 import time
 
 from fixtures import MonkeyPatch
+import six
 import transaction
 
 from lp.services.config import config
@@ -39,32 +40,51 @@ class TestWSGIArchiveAuth(TestCaseWithFactory):
             "lp.soyuz.wsgi.archiveauth._memcache_client",
             self.memcache_fixture))
         self.addCleanup(config.setInstance, config.instance_name)
+        self.resetLog()
+
+    def resetLog(self):
+        self.wsgi_errors = six.StringIO()
+
+    def assertLogs(self, message):
+        self.assertEqual(
+            [message] if message else [],
+            self.wsgi_errors.getvalue().splitlines())
 
     def test_get_archive_reference_short_url(self):
         self.assertIsNone(archiveauth._get_archive_reference(
-            {"SCRIPT_NAME": "/foo"}))
+            {"wsgi.errors": self.wsgi_errors, "SCRIPT_NAME": "/foo"}))
+        self.assertLogs("No archive reference found in URL '/foo'.")
 
     def test_get_archive_reference_archive_base(self):
         self.assertEqual(
             "~user/ubuntu/ppa",
             archiveauth._get_archive_reference(
-                {"SCRIPT_NAME": "/user/ppa/ubuntu"}))
+                {"wsgi.errors": self.wsgi_errors,
+                 "SCRIPT_NAME": "/user/ppa/ubuntu"}))
+        self.assertLogs("")
 
     def test_get_archive_reference_inside_archive(self):
         self.assertEqual(
             "~user/ubuntu/ppa",
             archiveauth._get_archive_reference(
-                {"SCRIPT_NAME": "/user/ppa/ubuntu/dists"}))
+                {"wsgi.errors": self.wsgi_errors,
+                 "SCRIPT_NAME": "/user/ppa/ubuntu/dists"}))
+        self.assertLogs("")
 
     def test_check_password_short_url(self):
         self.assertIsNone(archiveauth.check_password(
-            {"SCRIPT_NAME": "/foo"}, "user", ""))
+            {"wsgi.errors": self.wsgi_errors, "SCRIPT_NAME": "/foo"},
+            "user", ""))
         self.assertEqual({}, self.memcache_fixture._cache)
+        self.assertLogs("No archive reference found in URL '/foo'.")
 
     def test_check_password_not_found(self):
         self.assertIsNone(archiveauth.check_password(
-            {"SCRIPT_NAME": "/nonexistent/bad/unknown"}, "user", ""))
+            {"wsgi.errors": self.wsgi_errors,
+             "SCRIPT_NAME": "/nonexistent/bad/unknown"},
+            "user", ""))
         self.assertEqual({}, self.memcache_fixture._cache)
+        self.assertLogs("No archive found for '~nonexistent/unknown/bad'.")
 
     def test_crypt_sha256(self):
         crypted_password = archiveauth._crypt_sha256("secret")
@@ -81,25 +101,33 @@ class TestWSGIArchiveAuth(TestCaseWithFactory):
         return archive, archive_path, subscriber.name, token.token
 
     def test_check_password_unauthorized(self):
-        _, archive_path, username, password = self.makeArchiveAndToken()
+        archive, archive_path, username, password = self.makeArchiveAndToken()
         # Test that this returns False, not merely something falsy (e.g.
         # None).
         self.assertIs(
             False,
             archiveauth.check_password(
-                {"SCRIPT_NAME": archive_path}, username, password + "-bad"))
+                {"wsgi.errors": self.wsgi_errors, "SCRIPT_NAME": archive_path},
+                username, password + "-bad"))
         self.assertEqual({}, self.memcache_fixture._cache)
+        self.assertLogs(
+            "%s@~%s/ubuntu/%s: Password does not match." % (
+                username, archive.owner.name, archive.name))
 
     def test_check_password_success(self):
         archive, archive_path, username, password = self.makeArchiveAndToken()
         self.assertIs(
             True,
             archiveauth.check_password(
-                {"SCRIPT_NAME": archive_path}, username, password))
+                {"wsgi.errors": self.wsgi_errors, "SCRIPT_NAME": archive_path},
+                username, password))
         crypted_password = self.memcache_fixture.get(
             "archive-auth:%s:%s" % (archive.reference, username))
         self.assertEqual(
             crypted_password, crypt.crypt(password, crypted_password))
+        self.assertLogs(
+            "%s@~%s/ubuntu/%s: Authorized." % (
+                username, archive.owner.name, archive.name))
 
     def test_check_password_considers_cache(self):
         class FakeProxy:
@@ -110,28 +138,51 @@ class TestWSGIArchiveAuth(TestCaseWithFactory):
                                       password):
                 raise faults.Unauthorized()
 
-        _, archive_path, username, password = self.makeArchiveAndToken()
+        archive, archive_path, username, password = self.makeArchiveAndToken()
         self.assertIs(
             True,
             archiveauth.check_password(
-                {"SCRIPT_NAME": archive_path}, username, password))
+                {"wsgi.errors": self.wsgi_errors, "SCRIPT_NAME": archive_path},
+                username, password))
+        self.assertLogs(
+            "%s@~%s/ubuntu/%s: Authorized." % (
+                username, archive.owner.name, archive.name))
+        self.resetLog()
+
         self.useFixture(
             MonkeyPatch("lp.soyuz.wsgi.archiveauth.ServerProxy", FakeProxy))
+
         # A subsequent check honours the cache.
         self.assertIs(
             False,
             archiveauth.check_password(
-                {"SCRIPT_NAME": archive_path}, username, password + "-bad"))
+                {"wsgi.errors": self.wsgi_errors, "SCRIPT_NAME": archive_path},
+                username, password + "-bad"))
+        self.assertLogs(
+            "%s@~%s/ubuntu/%s: Password does not match." % (
+                username, archive.owner.name, archive.name))
+        self.resetLog()
+
         self.assertIs(
             True,
             archiveauth.check_password(
-                {"SCRIPT_NAME": archive_path}, username, password))
+                {"wsgi.errors": self.wsgi_errors, "SCRIPT_NAME": archive_path},
+                username, password))
+        self.assertLogs(
+            "%s@~%s/ubuntu/%s: Authorized (cached)." % (
+                username, archive.owner.name, archive.name))
+        self.resetLog()
+
         # If we advance time far enough, then the cached result expires.
         self.now += 60
         self.assertIs(
             False,
             archiveauth.check_password(
-                {"SCRIPT_NAME": archive_path}, username, password))
+                {"wsgi.errors": self.wsgi_errors, "SCRIPT_NAME": archive_path},
+                username, password))
+        self.assertLogs(
+            "%s@~%s/ubuntu/%s: Password does not match." % (
+                username, archive.owner.name, archive.name))
 
     def test_check_password_sets_config_instance(self):
         test_instance_name = self.factory.getUniqueUnicode()
@@ -142,10 +193,14 @@ class TestWSGIArchiveAuth(TestCaseWithFactory):
         self.assertIs(
             True,
             archiveauth.check_password(
-                {"mod_wsgi.application_group": test_instance_name,
+                {"wsgi.errors": self.wsgi_errors,
+                 "mod_wsgi.application_group": test_instance_name,
                  "SCRIPT_NAME": archive_path},
                 username, password))
         self.assertEqual(test_instance_name, config.instance_name)
+        self.assertLogs(
+            "%s@~%s/ubuntu/%s: Authorized." % (
+                username, archive.owner.name, archive.name))
 
     def test_script(self):
         _, archive_path, username, password = self.makeArchiveAndToken()
diff --git a/lib/lp/soyuz/xmlrpc/archive.py b/lib/lp/soyuz/xmlrpc/archive.py
index 8507873..63cda23 100644
--- a/lib/lp/soyuz/xmlrpc/archive.py
+++ b/lib/lp/soyuz/xmlrpc/archive.py
@@ -10,6 +10,8 @@ __all__ = [
     'ArchiveAPI',
     ]
 
+import logging
+
 from pymacaroons import Macaroon
 from zope.component import (
     ComponentLookupError,
@@ -30,6 +32,9 @@ from lp.xmlrpc import faults
 from lp.xmlrpc.helpers import return_fault
 
 
+log = logging.getLogger(__name__)
+
+
 BUILDD_USER_NAME = "buildd"
 
 
@@ -59,6 +64,7 @@ class ArchiveAPI(LaunchpadXMLRPCView):
     def _checkArchiveAuthToken(self, archive_reference, username, password):
         archive = getUtility(IArchiveSet).getByReference(archive_reference)
         if archive is None:
+            log.info("%s@%s: No archive found", username, archive_reference)
             raise faults.NotFound(
                 message="No archive found for '%s'." % archive_reference)
         archive = removeSecurityProxy(archive)
@@ -69,8 +75,12 @@ class ArchiveAPI(LaunchpadXMLRPCView):
         if username == BUILDD_USER_NAME:
             if self._verifyMacaroon(archive, password):
                 # Success.
+                log.info("%s@%s: Authorized", username, archive_reference)
                 return
             else:
+                log.info(
+                    "%s@%s: Macaroon verification failed",
+                    username, archive_reference)
                 raise faults.Unauthorized()
 
         # Fall back to checking archive auth tokens.
@@ -81,12 +91,17 @@ class ArchiveAPI(LaunchpadXMLRPCView):
             token = token_set.getActiveTokenForArchiveAndPersonName(
                 archive, username)
         if token is None:
+            log.info("%s@%s: No valid tokens", username, archive_reference)
             raise faults.NotFound(
                 message="No valid tokens for '%s' in '%s'." % (
                     username, archive_reference))
         secret = removeSecurityProxy(token).token
         if password != secret:
+            log.info(
+                "%s@%s: Password does not match", username, archive_reference)
             raise faults.Unauthorized()
+        else:
+            log.info("%s@%s: Authorized", username, archive_reference)
 
     def checkArchiveAuthToken(self, archive_reference, username, password):
         """See `IArchiveAPI`."""
diff --git a/lib/lp/soyuz/xmlrpc/tests/test_archive.py b/lib/lp/soyuz/xmlrpc/tests/test_archive.py
index 86c6ffa..028a97b 100644
--- a/lib/lp/soyuz/xmlrpc/tests/test_archive.py
+++ b/lib/lp/soyuz/xmlrpc/tests/test_archive.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 
+from fixtures import FakeLogger
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -30,35 +31,46 @@ class TestArchiveAPI(TestCaseWithFactory):
         self.archive_api = ArchiveAPI(None, None)
         self.pushConfig(
             "launchpad", internal_macaroon_secret_key="some-secret")
+        self.logger = self.useFixture(FakeLogger())
 
-    def assertNotFound(self, archive_reference, username, password, message):
+    def assertLogs(self, message):
+        self.assertEqual([message], self.logger.output.splitlines())
+
+    def assertNotFound(self, archive_reference, username, password, message,
+                       log_message):
         """Assert that an archive auth token check returns NotFound."""
         fault = self.archive_api.checkArchiveAuthToken(
             archive_reference, username, password)
         self.assertEqual(faults.NotFound(message), fault)
+        self.assertLogs(log_message)
 
-    def assertUnauthorized(self, archive_reference, username, password):
+    def assertUnauthorized(self, archive_reference, username, password,
+                           log_message):
         """Assert that an archive auth token check returns Unauthorized."""
         fault = self.archive_api.checkArchiveAuthToken(
             archive_reference, username, password)
         self.assertEqual(faults.Unauthorized("Authorisation required."), fault)
+        self.assertLogs(log_message)
 
     def test_checkArchiveAuthToken_unknown_archive(self):
         self.assertNotFound(
             "~nonexistent/unknown/bad", "user", "",
-            "No archive found for '~nonexistent/unknown/bad'.")
+            "No archive found for '~nonexistent/unknown/bad'.",
+            "user@~nonexistent/unknown/bad: No archive found")
 
     def test_checkArchiveAuthToken_no_tokens(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
         self.assertNotFound(
             archive.reference, "nobody", "",
-            "No valid tokens for 'nobody' in '%s'." % archive.reference)
+            "No valid tokens for 'nobody' in '%s'." % archive.reference,
+            "nobody@%s: No valid tokens" % archive.reference)
 
     def test_checkArchiveAuthToken_no_named_tokens(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
         self.assertNotFound(
             archive.reference, "+missing", "",
-            "No valid tokens for '+missing' in '%s'." % archive.reference)
+            "No valid tokens for '+missing' in '%s'." % archive.reference,
+            "+missing@%s: No valid tokens" % archive.reference)
 
     def test_checkArchiveAuthToken_buildd_macaroon_wrong_archive(self):
         archive = self.factory.makeArchive(private=True)
@@ -70,7 +82,9 @@ class TestArchiveAPI(TestCaseWithFactory):
             getUtility(IMacaroonIssuer, "binary-package-build"))
         macaroon = issuer.issueMacaroon(build)
         self.assertUnauthorized(
-            other_archive.reference, "buildd", macaroon.serialize())
+            other_archive.reference, "buildd", macaroon.serialize(),
+            "buildd@%s: Macaroon verification failed" %
+            other_archive.reference)
 
     def test_checkArchiveAuthToken_buildd_macaroon_not_building(self):
         archive = self.factory.makeArchive(private=True)
@@ -79,7 +93,8 @@ class TestArchiveAPI(TestCaseWithFactory):
             getUtility(IMacaroonIssuer, "binary-package-build"))
         macaroon = issuer.issueMacaroon(build)
         self.assertUnauthorized(
-            archive.reference, "buildd", macaroon.serialize())
+            archive.reference, "buildd", macaroon.serialize(),
+            "buildd@%s: Macaroon verification failed" % archive.reference)
 
     def test_checkArchiveAuthToken_buildd_macaroon_wrong_user(self):
         archive = self.factory.makeArchive(private=True)
@@ -90,7 +105,8 @@ class TestArchiveAPI(TestCaseWithFactory):
         macaroon = issuer.issueMacaroon(build)
         self.assertNotFound(
             archive.reference, "another-user", macaroon.serialize(),
-            "No valid tokens for 'another-user' in '%s'." % archive.reference)
+            "No valid tokens for 'another-user' in '%s'." % archive.reference,
+            "another-user@%s: No valid tokens" % archive.reference)
 
     def test_checkArchiveAuthToken_buildd_macaroon_correct(self):
         archive = self.factory.makeArchive(private=True)
@@ -101,26 +117,30 @@ class TestArchiveAPI(TestCaseWithFactory):
         macaroon = issuer.issueMacaroon(build)
         self.assertIsNone(self.archive_api.checkArchiveAuthToken(
             archive.reference, "buildd", macaroon.serialize()))
+        self.assertLogs("buildd@%s: Authorized" % archive.reference)
 
     def test_checkArchiveAuthToken_named_token_wrong_password(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
         token = archive.newNamedAuthToken("special")
-        removeSecurityProxy(token).deactivate()
-        self.assertNotFound(
-            archive.reference, "+special", token.token,
-            "No valid tokens for '+special' in '%s'." % archive.reference)
+        self.assertUnauthorized(
+            archive.reference, "+special", token.token + "-bad",
+            "+special@%s: Password does not match" % archive.reference)
 
     def test_checkArchiveAuthToken_named_token_deactivated(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
         token = archive.newNamedAuthToken("special")
-        self.assertIsNone(self.archive_api.checkArchiveAuthToken(
-            archive.reference, "+special", token.token))
+        removeSecurityProxy(token).deactivate()
+        self.assertNotFound(
+            archive.reference, "+special", token.token,
+            "No valid tokens for '+special' in '%s'." % archive.reference,
+            "+special@%s: No valid tokens" % archive.reference)
 
     def test_checkArchiveAuthToken_named_token_correct_password(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
         token = archive.newNamedAuthToken("special")
         self.assertIsNone(self.archive_api.checkArchiveAuthToken(
             archive.reference, "+special", token.token))
+        self.assertLogs("+special@%s: Authorized" % archive.reference)
 
     def test_checkArchiveAuthToken_personal_token_wrong_password(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
@@ -128,7 +148,9 @@ class TestArchiveAPI(TestCaseWithFactory):
         archive.newSubscription(subscriber, archive.owner)
         token = archive.newAuthToken(subscriber)
         self.assertUnauthorized(
-            archive.reference, subscriber.name, token.token + "-bad")
+            archive.reference, subscriber.name, token.token + "-bad",
+            "%s@%s: Password does not match" % (
+                subscriber.name, archive.reference))
 
     def test_checkArchiveAuthToken_personal_token_deactivated(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
@@ -139,7 +161,8 @@ class TestArchiveAPI(TestCaseWithFactory):
         self.assertNotFound(
             archive.reference, subscriber.name, token.token,
             "No valid tokens for '%s' in '%s'." % (
-                subscriber.name, archive.reference))
+                subscriber.name, archive.reference),
+            "%s@%s: No valid tokens" % (subscriber.name, archive.reference))
 
     def test_checkArchiveAuthToken_personal_token_cancelled(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
@@ -150,7 +173,8 @@ class TestArchiveAPI(TestCaseWithFactory):
         self.assertNotFound(
             archive.reference, subscriber.name, token.token,
             "No valid tokens for '%s' in '%s'." % (
-                subscriber.name, archive.reference))
+                subscriber.name, archive.reference),
+            "%s@%s: No valid tokens" % (subscriber.name, archive.reference))
 
     def test_checkArchiveAuthToken_personal_token_correct_password(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
@@ -159,3 +183,5 @@ class TestArchiveAPI(TestCaseWithFactory):
         token = archive.newAuthToken(subscriber)
         self.assertIsNone(self.archive_api.checkArchiveAuthToken(
             archive.reference, subscriber.name, token.token))
+        self.assertLogs(
+            "%s@%s: Authorized" % (subscriber.name, archive.reference))
diff --git a/scripts/wsgi-archive-auth.py b/scripts/wsgi-archive-auth.py
index 3640ed1..f447b6a 100755
--- a/scripts/wsgi-archive-auth.py
+++ b/scripts/wsgi-archive-auth.py
@@ -55,10 +55,8 @@ def main():
     result = check_password(
         {"SCRIPT_NAME": args.archive_path}, args.username, args.password)
     if result is None:
-        print("Archive or user does not exist.", file=sys.stderr)
         return 2
     elif result is False:
-        print("Password does not match.", file=sys.stderr)
         return 1
     elif result is True:
         return 0