← Back to team overview

launchpad-reviewers team mailing list archive

Re: [Merge] lp:~wgrant/launchpad/sca-api into lp:launchpad

 

Review: Approve



Diff comments:

> === modified file 'lib/lp/registry/browser/tests/test_person_webservice.py'
> --- lib/lp/registry/browser/tests/test_person_webservice.py	2015-01-07 00:35:53 +0000
> +++ lib/lp/registry/browser/tests/test_person_webservice.py	2015-03-10 11:46:53 +0000
> @@ -1,8 +1,9 @@
> -# Copyright 2009 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  __metaclass__ = type
>  
> +from storm.store import Store
>  from testtools.matchers import Equals
>  from zope.component import getUtility
>  from zope.security.management import endInteraction
> @@ -14,6 +15,7 @@
>      )
>  from lp.registry.interfaces.teammembership import ITeamMembershipSet
>  from lp.services.identity.interfaces.account import AccountStatus
> +from lp.services.openid.model.openididentifier import OpenIdIdentifier
>  from lp.services.webapp.interfaces import OAuthPermission
>  from lp.testing import (
>      admin_logged_in,
> @@ -282,3 +284,61 @@
>                  'identifier=http://login1.dev/%%2Bid/%s'
>                  % person_openid,
>                  api_version='devel').jsonBody()['name'])
> +
> +    def getOrCreateSoftwareCenterCustomer(self, user):
> +        webservice = webservice_for_person(
> +            user, permission=OAuthPermission.WRITE_PRIVATE)
> +        response = webservice.named_post(
> +            '/people', 'getOrCreateSoftwareCenterCustomer',
> +            openid_identifier='somebody',
> +            email_address='somebody@xxxxxxxxxxx', display_name='Somebody',
> +            api_version='devel')
> +        return response
> +
> +    def test_getOrCreateSoftwareCenterCustomer(self):
> +        # Software Center Agent (SCA) can get or create people by OpenID
> +        # identifier.
> +        with admin_logged_in():
> +            sca = getUtility(IPersonSet).getByName('software-center-agent')
> +        response = self.getOrCreateSoftwareCenterCustomer(sca)
> +        self.assertEqual('Somebody', response.jsonBody()['display_name'])
> +        with admin_logged_in():
> +            person = getUtility(IPersonSet).getByEmail('somebody@xxxxxxxxxxx')
> +            self.assertEqual('Somebody', person.displayname)
> +            self.assertEqual(
> +                ['somebody'],
> +                [oid.identifier for oid in person.account.openid_identifiers])
> +            self.assertEqual(
> +                'somebody@xxxxxxxxxxx', person.preferredemail.email)
> +
> +    def test_getOrCreateSoftwareCenterCustomer_is_restricted(self):
> +        # The method may only be invoked by the ~software-center-agent
> +        # celebrity user, as it is security-sensitive.
> +        with admin_logged_in():
> +            random = self.factory.makePerson()
> +        response = self.getOrCreateSoftwareCenterCustomer(random)
> +        self.assertEqual(401, response.status)
> +
> +    def test_getOrCreateSoftwareCenterCustomer_rejects_email_conflicts(self):
> +        # An unknown OpenID identifier with a known email address causes
> +        # the request to fail with 409 Conflict, as we'd otherwise end
> +        # up linking the OpenID identifier to an existing account.
> +        with admin_logged_in():
> +            self.factory.makePerson(email='somebody@xxxxxxxxxxx')
> +            sca = getUtility(IPersonSet).getByName('software-center-agent')
> +        response = self.getOrCreateSoftwareCenterCustomer(sca)
> +        self.assertEqual(409, response.status)
> +
> +    def test_getOrCreateSoftwareCenterCustomer_rejects_suspended(self):
> +        # Suspended accounts are not returned.
> +        with admin_logged_in():
> +            existing = self.factory.makePerson(
> +                email='somebody@xxxxxxxxxxx',
> +                account_status=AccountStatus.SUSPENDED)
> +            oid = OpenIdIdentifier()
> +            oid.account = existing.account
> +            oid.identifier = u'somebody'
> +            Store.of(existing).add(oid)
> +            sca = getUtility(IPersonSet).getByName('software-center-agent')
> +        response = self.getOrCreateSoftwareCenterCustomer(sca)
> +        self.assertEqual(400, response.status)
> 
> === modified file 'lib/lp/registry/interfaces/person.py'
> --- lib/lp/registry/interfaces/person.py	2015-02-26 11:34:47 +0000
> +++ lib/lp/registry/interfaces/person.py	2015-03-10 11:46:53 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """Person interfaces."""
> @@ -2173,6 +2173,30 @@
>              identifier has been suspended.
>          """
>  
> +    @call_with(user=REQUEST_USER)
> +    @operation_parameters(
> +        openid_identifier=TextLine(
> +            title=_("OpenID identifier suffix"), required=True),
> +        email_address=TextLine(title=_("Email address"), required=True),
> +        display_name=TextLine(title=_("Display name"), required=True))
> +    @export_write_operation()
> +    @operation_for_version("devel")
> +    def getOrCreateSoftwareCenterCustomer(user, openid_identifier,
> +                                          email_address, display_name):
> +        """Restricted person creation API for Software Center Agent.
> +
> +        This method can only be called by Software Center Agent. It gets
> +        a person by OpenID identifier or creates a new Launchpad person
> +        from the OpenID identifier, email address and display name.
> +
> +        :param user: the `IPerson` performing the operation. Only the
> +            software-center-agent celebrity is allowed.
> +        :param openid_identifier: OpenID identifier suffix for the user.
> +            This is *not* the full URL, just the unique suffix portion.
> +        :param email_address: the email address of the user.
> +        :param full_name: the full name of the user.

The parameter name is "display_name" rather than "full_name" earlier, although the old XML-RPC interface spells it "full_name".

> +        """
> +
>      @call_with(teamowner=REQUEST_USER)
>      @rename_parameters_as(
>          displayname='display_name', teamdescription='team_description',
> 
> === modified file 'lib/lp/registry/model/person.py'
> --- lib/lp/registry/model/person.py	2015-03-04 19:05:47 +0000
> +++ lib/lp/registry/model/person.py	2015-03-10 11:46:53 +0000
> @@ -268,6 +268,7 @@
>      INACTIVE_ACCOUNT_STATUSES,
>      )
>  from lp.services.identity.interfaces.emailaddress import (
> +    EmailAddressAlreadyTaken,
>      EmailAddressStatus,
>      IEmailAddress,
>      IEmailAddressSet,
> @@ -3317,8 +3318,26 @@
>          return IPerson(account)
>  
>      def getOrCreateByOpenIDIdentifier(self, openid_identifier, email_address,
> -                                      full_name, creation_rationale, comment):
> +                                      full_name, creation_rationale, comment,
> +                                      trust_email=True):
>          """See `IPersonSet`."""
> +        # trust_email is an internal flag used by
> +        # getOrCreateSoftwareCenterCustomer. We don't want SCA to be
> +        # able to use the API to associate arbitrary OpenID identifiers
> +        # and email addresses when that could compromise existing
> +        # accounts.
> +        #
> +        # To that end, if trust_email is not set then the given
> +        # email address will only be used to create an account. It will
> +        # never be used to look up an account, nor will it be added to
> +        # an existing account. This causes two additional cases to be
> +        # rejected: unknown OpenID identifier but known email address,
> +        # and deactivated account.
> +        #
> +        # Exempting account creation and activation from this rule opens
> +        # us to a potential account fixation attack, but the risk is
> +        # minimal.
> +
>          assert email_address is not None and full_name is not None, (
>              "Both email address and full name are required to create an "
>              "account.")
> @@ -3341,13 +3360,19 @@
>                  # We don't know about the OpenID identifier yet, so try
>                  # to match a person by email address, or as a last
>                  # resort create a new one.
> -                if email is not None:
> -                    person = email.person
> -                else:
> +                if email is None:
>                      person_set = getUtility(IPersonSet)
>                      person, email = person_set.createPersonAndEmail(
>                          email_address, creation_rationale, comment=comment,
>                          displayname=full_name)
> +                elif trust_email:
> +                    person = email.person
> +                else:
> +                    # The email address originated from a source that's
> +                    # not completely trustworth (eg. SCA), so we can't
> +                    # use it to link the OpenID identifier to an
> +                    # existing person.
> +                    raise EmailAddressAlreadyTaken()
>  
>                  # It's possible that the email address is owned by a
>                  # team. Reject the login attempt, and wait for the user
> @@ -3373,10 +3398,20 @@
>              person = IPerson(identifier.account, None)
>              assert person is not None, ('Received a personless account.')
>  
> -            if person.account.status == AccountStatus.SUSPENDED:
> +            status = person.account.status
> +            if status == AccountStatus.ACTIVE:
> +                # Account is active, so nothing to do.
> +                pass
> +            elif status == AccountStatus.SUSPENDED:
>                  raise AccountSuspendedError(
>                      "The account matching the identifier is suspended.")
> -
> +            elif not trust_email and status != AccountStatus.NOACCOUNT:
> +                # If the email address is not completely trustworthy
> +                # (ie. it comes from SCA) and the account has already
> +                # been used, then we don't want to proceed as we might
> +                # end up adding a malicious OpenID identifier to an
> +                # existing account.
> +                raise AccountSuspendedError()

A slightly opaque choice.  Wouldn't a new exception with @error_status(httplib.CONFLICT) be clearer, or maybe just NameAlreadyTaken?

>              elif person.account.status in [AccountStatus.DEACTIVATED,
>                                             AccountStatus.NOACCOUNT]:
>                  removeSecurityProxy(person.account).reactivate(comment)
> @@ -3386,11 +3421,24 @@
>                  removeSecurityProxy(person).setPreferredEmail(email)
>                  db_updated = True
>              else:
> -                # Account is active, so nothing to do.
> -                pass
> +                raise AssertionError(
> +                    "Unhandled account status: %r" % person.account.status)
>  
>              return person, db_updated
>  
> +    def getOrCreateSoftwareCenterCustomer(self, user, openid_identifier,
> +                                          email_address, display_name):
> +        """See `IPersonSet`."""
> +        if user != getUtility(ILaunchpadCelebrities).software_center_agent:
> +            raise Unauthorized()
> +        person, _ = getUtility(
> +            IPersonSet).getOrCreateByOpenIDIdentifier(
> +                openid_identifier, email_address, display_name,
> +                PersonCreationRationale.SOFTWARE_CENTER_PURCHASE,
> +                "when purchasing an application via Software Center.",
> +                trust_email=False)
> +        return person
> +
>      def newTeam(self, teamowner, name, displayname, teamdescription=None,
>                  membership_policy=TeamMembershipPolicy.MODERATED,
>                  defaultmembershipperiod=None, defaultrenewalperiod=None,
> 
> === modified file 'lib/lp/registry/tests/test_personset.py'
> --- lib/lp/registry/tests/test_personset.py	2015-01-07 00:35:53 +0000
> +++ lib/lp/registry/tests/test_personset.py	2015-03-10 11:46:53 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """Tests for PersonSet."""
> @@ -9,6 +9,7 @@
>  from testtools.matchers import LessThan
>  import transaction
>  from zope.component import getUtility
> +from zope.security.interfaces import Unauthorized
>  from zope.security.proxy import removeSecurityProxy
>  
>  from lp.code.tests.helpers import remove_all_sample_data_branches
> @@ -41,13 +42,17 @@
>  from lp.services.identity.interfaces.emailaddress import (
>      EmailAddressAlreadyTaken,
>      EmailAddressStatus,
> +    IEmailAddressSet,
>      InvalidEmailAddress,
>      )
>  from lp.services.identity.model.account import Account
>  from lp.services.identity.model.emailaddress import EmailAddress
>  from lp.services.openid.model.openididentifier import OpenIdIdentifier
> +from lp.services.webapp.interfaces import ILaunchBag
>  from lp.testing import (
> +    admin_logged_in,
>      ANONYMOUS,
> +    anonymous_logged_in,
>      login,
>      logout,
>      person_logged_in,
> @@ -590,3 +595,135 @@
>              u'other-openid-identifier' in [
>                  identifier.identifier for identifier in removeSecurityProxy(
>                      person.account).openid_identifiers])
> +
> +
> +class TestPersonSetGetOrCreateSoftwareCenterCustomer(TestCaseWithFactory):
> +
> +    layer = DatabaseFunctionalLayer
> +
> +    def setUp(self):
> +        super(TestPersonSetGetOrCreateSoftwareCenterCustomer, self).setUp()
> +        self.sca = getUtility(IPersonSet).getByName('software-center-agent')
> +
> +    def makeOpenIdIdentifier(self, account, identifier):
> +        openid_identifier = OpenIdIdentifier()
> +        openid_identifier.identifier = identifier
> +        openid_identifier.account = account
> +        return IStore(OpenIdIdentifier).add(openid_identifier)
> +
> +    def test_restricted_to_sca(self):
> +        # Only the software-center-agent celebrity can invoke this
> +        # privileged method.
> +        def do_it():
> +            return getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
> +                getUtility(ILaunchBag).user, u'somebody',
> +                'somebody@xxxxxxxxxxx', 'Example')
> +        random = self.factory.makePerson()
> +        admin = self.factory.makePerson(
> +            member_of=[getUtility(IPersonSet).getByName('admins')])
> +
> +        # Anonymous, random or admin users can't invoke the method.
> +        with anonymous_logged_in():
> +            self.assertRaises(Unauthorized, do_it)
> +        with person_logged_in(random):
> +            self.assertRaises(Unauthorized, do_it)
> +        with person_logged_in(admin):
> +            self.assertRaises(Unauthorized, do_it)
> +
> +        with person_logged_in(self.sca):
> +            person = do_it()
> +        self.assertIsInstance(person, Person)
> +
> +    def test_finds_by_openid(self):
> +        # A Person with the requested OpenID identifier is returned.
> +        somebody = self.factory.makePerson()
> +        self.makeOpenIdIdentifier(somebody.account, u'somebody')
> +        with person_logged_in(self.sca):
> +            got = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
> +                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
> +        self.assertEqual(somebody, got)
> +
> +        # The email address doesn't get linked, as that could change how
> +        # future logins work.
> +        self.assertIs(
> +            None,

self.assertIsNone() to save a line break.

> +            getUtility(IEmailAddressSet).getByEmail('somebody@xxxxxxxxxxx'))
> +
> +    def test_creates_new(self):
> +        # If an unknown OpenID identifier and email address are
> +        # provided, a new account is created and returned.
> +        with person_logged_in(self.sca):
> +            made = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
> +                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
> +        with admin_logged_in():
> +            self.assertEqual('Example', made.displayname)
> +            self.assertEqual('somebody@xxxxxxxxxxx', made.preferredemail.email)
> +
> +        # The email address is linked, since it can't compromise an
> +        # account that is being created just for it.
> +        email = getUtility(IEmailAddressSet).getByEmail('somebody@xxxxxxxxxxx')
> +        self.assertEqual(made, email.person)
> +
> +    def test_activates_unactivated(self):
> +        # An unactivated account should be treated just like a new
> +        # account -- it gets activated with the given email address.
> +        somebody = self.factory.makePerson(
> +            email='existing@xxxxxxxxxxx',
> +            account_status=AccountStatus.NOACCOUNT)
> +        self.makeOpenIdIdentifier(somebody.account, u'somebody')
> +        self.assertEqual(AccountStatus.NOACCOUNT, somebody.account.status)
> +        with person_logged_in(self.sca):
> +            got = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
> +                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
> +        self.assertEqual(somebody, got)
> +        with admin_logged_in():
> +            self.assertEqual(AccountStatus.ACTIVE, somebody.account.status)
> +            self.assertEqual(
> +                'somebody@xxxxxxxxxxx', somebody.preferredemail.email)
> +
> +    def test_fails_if_email_is_already_registered(self):
> +        # Only the OpenID identifier is used to look up an account. If
> +        # the OpenID identifier isn't already registered by the email

Took me a couple of readings, but presumably s/by/but/.

> +        # address is, the request is rejected to avoid potentially
> +        # adding an unwanted OpenID identifier to the address' account.
> +        #
> +        # The user must log into Launchpad directly first to register
> +        # their OpenID identifier.
> +        other = self.factory.makePerson(email='other@xxxxxxxxxxx')
> +        with person_logged_in(self.sca):
> +            self.assertRaises(
> +                EmailAddressAlreadyTaken,
> +                getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
> +                self.sca, u'somebody', 'other@xxxxxxxxxxx', 'Example')
> +
> +        # The email address stays with the old owner.
> +        email = getUtility(IEmailAddressSet).getByEmail('other@xxxxxxxxxxx')
> +        self.assertEqual(other, email.person)
> +
> +    def test_fails_if_account_is_suspended(self):
> +        # Suspended accounts cannot be returned.
> +        somebody = self.factory.makePerson()
> +        self.makeOpenIdIdentifier(somebody.account, u'somebody')
> +        with admin_logged_in():
> +            somebody.setAccountStatus(
> +                AccountStatus.SUSPENDED, None, "Go away!")
> +        with person_logged_in(self.sca):
> +            self.assertRaises(
> +                AccountSuspendedError,
> +                getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
> +                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
> +
> +    def test_fails_if_account_is_deactivated(self):
> +        # We don't want to reactivate explicitly deactivated accounts,
> +        # nor do we want to potentially compromise them with a bad email
> +        # address.
> +        somebody = self.factory.makePerson()
> +        self.makeOpenIdIdentifier(somebody.account, u'somebody')
> +        with admin_logged_in():
> +            somebody.setAccountStatus(
> +                AccountStatus.DEACTIVATED, None, "Goodbye cruel world.")
> +        with person_logged_in(self.sca):
> +            self.assertRaises(
> +                AccountSuspendedError,
> +                getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
> +                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
> 
> === modified file 'lib/lp/services/identity/interfaces/account.py'
> --- lib/lp/services/identity/interfaces/account.py	2015-01-07 00:35:53 +0000
> +++ lib/lp/services/identity/interfaces/account.py	2015-03-10 11:46:53 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """Account interfaces."""
> @@ -18,11 +18,13 @@
>      'INACTIVE_ACCOUNT_STATUSES',
>      ]
>  
> +import httplib
>  
>  from lazr.enum import (
>      DBEnumeratedType,
>      DBItem,
>      )
> +from lazr.restful.declarations import error_status
>  from zope.interface import (
>      Attribute,
>      Interface,
> @@ -40,6 +42,7 @@
>  from lp.services.fields import StrippedTextLine
>  
>  
> +@error_status(httplib.BAD_REQUEST)
>  class AccountSuspendedError(Exception):
>      """The account being accessed has been suspended."""
>  
> 
> === modified file 'lib/lp/services/identity/interfaces/emailaddress.py'
> --- lib/lp/services/identity/interfaces/emailaddress.py	2013-01-07 02:40:55 +0000
> +++ lib/lp/services/identity/interfaces/emailaddress.py	2015-03-10 11:46:53 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """EmailAddress interfaces."""
> @@ -12,11 +12,14 @@
>      'InvalidEmailAddress',
>      'VALID_EMAIL_STATUSES']
>  
> +import httplib
> +
>  from lazr.enum import (
>      DBEnumeratedType,
>      DBItem,
>      )
>  from lazr.restful.declarations import (
> +    error_status,
>      export_as_webservice_entry,
>      exported,
>      )
> @@ -36,6 +39,7 @@
>      """The email address is not valid."""
>  
>  
> +@error_status(httplib.CONFLICT)
>  class EmailAddressAlreadyTaken(Exception):
>      """The email address is already registered in Launchpad."""
>  
> 


-- 
https://code.launchpad.net/~wgrant/launchpad/sca-api/+merge/252420
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.