← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/close-account-perms into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/close-account-perms into lp:launchpad.

Commit message:
Make close-account work as a non-superuser.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/close-account-perms/+merge/359773

I also Stormified and generally modernised close-account.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/close-account-perms into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2018-10-16 10:10:10 +0000
+++ database/schema/security.cfg	2018-11-28 18:58:57 +0000
@@ -333,7 +333,9 @@
 [launchpad]
 groups=launchpad_main
 type=user
+public.karma                            = SELECT, INSERT, UPDATE, DELETE
 public.sharingjob                       = SELECT, INSERT, UPDATE, DELETE
+public.signedcodeofconduct              = SELECT, INSERT, UPDATE, DELETE
 
 [script]
 public.accessartifact                   = SELECT

=== added file 'lib/lp/registry/scripts/closeaccount.py'
--- lib/lp/registry/scripts/closeaccount.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/scripts/closeaccount.py	2018-11-28 18:58:57 +0000
@@ -0,0 +1,193 @@
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Remove personal details of a user from the database, leaving a stub."""
+
+__metaclass__ = type
+__all__ = ['CloseAccountScript']
+
+from storm.expr import (
+    LeftJoin,
+    Lower,
+    Or,
+    )
+
+from lp.answers.enums import QuestionStatus
+from lp.answers.model.question import Question
+from lp.bugs.model.bugtask import BugTask
+from lp.registry.interfaces.person import PersonCreationRationale
+from lp.registry.model.person import Person
+from lp.services.database.interfaces import IMasterStore
+from lp.services.identity.model.account import Account
+from lp.services.identity.model.emailaddress import EmailAddress
+from lp.services.scripts.base import (
+    LaunchpadScript,
+    LaunchpadScriptFailure,
+    )
+
+
+def close_account(username, log):
+    """Close a person's account.
+
+    Return True on success, or log an error message and return False
+    """
+    store = IMasterStore(Person)
+
+    row = store.using(
+        Person,
+        LeftJoin(EmailAddress, Person.id == EmailAddress.personID)
+    ).find(
+        (Person.id, Person.accountID, Person.name, Person.teamownerID),
+        Or(Person.name == username,
+           Lower(EmailAddress.email) == Lower(username))).one()
+    if row is None:
+        raise LaunchpadScriptFailure("User %s does not exist" % username)
+    person_id, account_id, username, teamowner_id = row
+
+    # We don't do teams
+    if teamowner_id is not None:
+        raise LaunchpadScriptFailure("%s is a team" % username)
+
+    log.info("Closing %s's account" % username)
+
+    def table_notification(table):
+        log.debug("Handling the %s table" % table)
+
+    # All names starting with 'removed' are blacklisted, so this will always
+    # succeed.
+    new_name = 'removed%d' % person_id
+
+    # Remove the EmailAddress. This is the most important step, as
+    # people requesting account removal seem to primarily be interested
+    # in ensuring we no longer store this information.
+    table_notification('EmailAddress')
+    store.find(EmailAddress, EmailAddress.personID == person_id).remove()
+
+    # Clean out personal details from the Person table
+    table_notification('Person')
+    store.find(Person, Person.id == person_id).set(
+        display_name='Removed by request',
+        name=new_name,
+        accountID=None,
+        homepage_content=None,
+        iconID=None,
+        mugshotID=None,
+        hide_email_addresses=True,
+        registrantID=None,
+        logoID=None,
+        creation_rationale=PersonCreationRationale.UNKNOWN,
+        creation_comment=None)
+
+    # Remove the Account. We don't set the status to deactivated,
+    # as this script is used to satisfy people who insist on us removing
+    # all their personal details from our systems. This includes any
+    # identification tokens like email addresses or openid identifiers.
+    # So the Account record would be unusable, and contain no useful
+    # information.
+    table_notification('Account')
+    if account_id is not None:
+        store.find(Account, Account.id == account_id).remove()
+
+    # Reassign their bugs
+    table_notification('BugTask')
+    store.find(BugTask, BugTask.assigneeID == person_id).set(assigneeID=None)
+
+    # Reassign questions assigned to the user, and close all their questions
+    # since nobody else can
+    table_notification('Question')
+    store.find(Question, Question.assigneeID == person_id).set(assigneeID=None)
+    store.find(Question, Question.ownerID == person_id).set(
+        status=QuestionStatus.SOLVED,
+        whiteboard=(
+            'Closed by Launchpad due to owner requesting account removal'))
+
+    # Remove rows from tables in simple cases in the given order
+    removals = [
+        # Trash their email addresses. People who request complete account
+        # removal would be unhappy if they reregistered with their old email
+        # address and this resurrected their deleted account, as the email
+        # address is probably the piece of data we store that they were most
+        # concerned with being removed from our systems.
+        ('EmailAddress', 'person'),
+
+        # Trash their codes of conduct and GPG keys
+        ('SignedCodeOfConduct', 'owner'),
+        ('GpgKey', 'owner'),
+
+        # Subscriptions
+        ('BranchSubscription', 'person'),
+        ('GitSubscription', 'person'),
+        ('BugSubscription', 'person'),
+        ('QuestionSubscription', 'person'),
+        ('SpecificationSubscription', 'person'),
+
+        # Personal stuff, freeing up the namespace for others who want to play
+        # or just to remove any fingerprints identifying the user.
+        ('IrcId', 'person'),
+        ('JabberId', 'person'),
+        ('WikiName', 'person'),
+        ('PersonLanguage', 'person'),
+        ('PersonLocation', 'person'),
+        ('SshKey', 'person'),
+
+        # Karma
+        ('Karma', 'person'),
+        ('KarmaCache', 'person'),
+        ('KarmaTotalCache', 'person'),
+
+        # Team memberships
+        ('TeamMembership', 'person'),
+        ('TeamParticipation', 'person'),
+
+        # Contacts
+        ('AnswerContact', 'person'),
+
+        # Pending items in queues
+        ('POExportRequest', 'person'),
+
+        # Access grants
+        ('GitRuleGrant', 'grantee'),
+        ]
+    for table, person_id_column in removals:
+        table_notification(table)
+        store.execute("""
+            DELETE FROM %(table)s WHERE %(person_id_column)s = ?
+            """ % {
+                'table': table,
+                'person_id_column': person_id_column,
+                },
+            (person_id,))
+
+    # Trash Sprint Attendance records in the future.
+    table_notification('SprintAttendance')
+    store.execute("""
+        DELETE FROM SprintAttendance
+        USING Sprint
+        WHERE Sprint.id = SprintAttendance.sprint
+            AND attendee = ?
+            AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
+        """, (person_id,))
+
+    return True
+
+
+class CloseAccountScript(LaunchpadScript):
+
+    usage = '%prog [options] (username|email) [...]'
+    description = (
+        "Close a person's account, deleting as much personal information "
+        "as possible.")
+
+    def main(self):
+        if not self.args:
+            raise LaunchpadScriptFailure(
+                "Must specify username (Person.name)")
+
+        for username in self.args:
+            try:
+                close_account(unicode(username), self.logger)
+            except Exception:
+                self.txn.abort()
+                raise
+        self.logger.debug("Committing changes")
+        self.txn.commit()

=== added file 'lib/lp/registry/scripts/tests/test_closeaccount.py'
--- lib/lp/registry/scripts/tests/test_closeaccount.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/scripts/tests/test_closeaccount.py	2018-11-28 18:58:57 +0000
@@ -0,0 +1,113 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test the close-account script."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.registry.interfaces.person import IPersonSet
+from lp.registry.scripts.closeaccount import CloseAccountScript
+from lp.services.identity.interfaces.account import (
+    AccountStatus,
+    IAccountSet,
+    )
+from lp.services.log.logger import DevNullLogger
+from lp.services.scripts.base import LaunchpadScriptFailure
+from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
+from lp.testing.faketransaction import FakeTransaction
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestCloseAccount(TestCaseWithFactory):
+    """Test the close-account script.
+
+    Unfortunately, we have no way of detecting schema updates containing new
+    information that needs to be removed or sanitized on account closure
+    apart from reviewers noticing and prompting developers to update this
+    script.  See Bug #120506 for more details.
+    """
+
+    layer = ZopelessDatabaseLayer
+
+    def makeScript(self, test_args):
+        script = CloseAccountScript(test_args=test_args)
+        script.logger = DevNullLogger()
+        script.txn = FakeTransaction()
+        return script
+
+    def getSampleUser(self, name, email):
+        """Return a sampledata account with some personal information."""
+        person = getUtility(IPersonSet).getByEmail(email)
+        account = removeSecurityProxy(person.account)
+        self.assertEqual(AccountStatus.ACTIVE, account.status)
+        self.assertEqual(name, person.name)
+        return person.id, account.id
+
+    def assertRemoved(self, account_id, person_id):
+        # We can't just set the account to DEACTIVATED, as the
+        # close-account.py script is used to satisfy people who insist on us
+        # removing all their personal details from our system.  The Account
+        # has been removed entirely.
+        self.assertRaises(
+            LookupError, getUtility(IAccountSet).get, account_id)
+
+        # The Person record still exists to maintain links with information
+        # that won't be removed, such as bug comments, but has been
+        # anonymized.
+        person = getUtility(IPersonSet).get(person_id)
+        self.assertStartsWith(person.name, 'removed')
+        self.assertEqual('Removed by request', person.display_name)
+
+    def assertNotRemoved(self, account_id, person_id):
+        account = getUtility(IAccountSet).get(account_id)
+        self.assertEqual(AccountStatus.ACTIVE, account.status)
+        person = getUtility(IPersonSet).get(person_id)
+        self.assertIsNotNone(person.account)
+
+    def test_nonexistent(self):
+        script = self.makeScript(['nonexistent-person'])
+        with dbuser('launchpad'):
+            self.assertRaisesWithContent(
+                LaunchpadScriptFailure,
+                'User nonexistent-person does not exist',
+                script.main)
+
+    def test_team(self):
+        team = self.factory.makeTeam()
+        script = self.makeScript([team.name])
+        with dbuser('launchpad'):
+            self.assertRaisesWithContent(
+                LaunchpadScriptFailure,
+                '%s is a team' % team.name,
+                script.main)
+
+    def test_single_by_name(self):
+        person_id, account_id = self.getSampleUser('mark', 'mark@xxxxxxxxxxx')
+        script = self.makeScript(['mark'])
+        with dbuser('launchpad'):
+            script.main()
+        self.assertRemoved(account_id, person_id)
+
+    def test_single_by_email(self):
+        person_id, account_id = self.getSampleUser('mark', 'mark@xxxxxxxxxxx')
+        script = self.makeScript(['mark@xxxxxxxxxxx'])
+        with dbuser('launchpad'):
+            script.main()
+        self.assertRemoved(account_id, person_id)
+
+    def test_multiple(self):
+        persons = [self.factory.makePerson() for _ in range(3)]
+        person_ids = [person.id for person in persons]
+        account_ids = [person.account.id for person in persons]
+        script = self.makeScript([persons[0].name, persons[1].name])
+        with dbuser('launchpad'):
+            script.main()
+        self.assertRemoved(account_ids[0], person_ids[0])
+        self.assertRemoved(account_ids[1], person_ids[1])
+        self.assertNotRemoved(account_ids[2], person_ids[2])

=== removed file 'lib/lp/services/identity/doc/close-account.txt'
--- lib/lp/services/identity/doc/close-account.txt	2011-12-29 05:29:36 +0000
+++ lib/lp/services/identity/doc/close-account.txt	1970-01-01 00:00:00 +0000
@@ -1,83 +0,0 @@
-We have a command line script that can be used to close accounts.
-
-Unfortunately, we have no way of detecting schema updates containing new
-information that needs to be removed or sanitized on account closure apart
-from reviewers noticing and prompting developers to update this script.
-
-See Bug #120506 for more details.
-
-
-Get Mark's account and person entries.
->>> from lp.registry.interfaces.person import IPersonSet
->>> from zope.security.proxy import removeSecurityProxy
->>> mark_person = getUtility(IPersonSet).getByEmail('mark@xxxxxxxxxxx')
->>> mark_account = removeSecurityProxy(mark_person.account)
-
-
-Mark's account is active and contains personal information.
-
->>> print mark_account.status.name
-ACTIVE
->>> print mark_account.displayname
-Mark Shuttleworth
->>> print mark_person.name
-mark
->>> print mark_person.displayname
-Mark Shuttleworth
-
-
-Store the id's so we can retrieve the records later.
-
->>> mark_person_id = mark_person.id
->>> mark_account_id = mark_account.id
-
-
-Lets close his account.
-
->>> import os.path
->>> from lp.services.config import config
->>> from lp.testing.script import run_script
->>> script = os.path.join(config.root, 'scripts', 'close-account.py')
->>> (result, out, err) = run_script(script, args=['mark@xxxxxxxxxxx'])
->>> print result
-0
->>> print out
->>> print err
-INFO...Closing mark's account
-
-
-Now, start a new transaction so we can see the changes the script made.
-
->>> from lp.testing.layers import LaunchpadZopelessLayer
->>> LaunchpadZopelessLayer.abort()
-
-
-We can't just set the account to DEACTIVATED, as the close-account.py
-script is used to satisty people who insist on us removing all their
-personal details from our system. The Account has been removed entirely.
-
->>> from lp.services.identity.model.account import Account
->>> Account.get(mark_account_id)
-Traceback (most recent call last):
-...
-SQLObjectNotFound: ...
-
-
-The Person record still exists to maintain links with information that won't
-be removed, such as bug comments, but has been anonymized.
-
->>> from lp.registry.model.person import Person
->>> mark_person = Person.get(mark_person_id)
->>> print mark_person.name
-removed...
->>> print mark_person.displayname
-Removed by request
-
-
-Flag the database as dirty since it has been modified without the test suite
-knowing.
-
->>> from lp.testing.layers import DatabaseLayer
->>> DatabaseLayer.force_dirty_database()
-
-

=== modified file 'lib/lp/services/identity/tests/test_doc.py'
--- lib/lp/services/identity/tests/test_doc.py	2012-01-01 02:58:52 +0000
+++ lib/lp/services/identity/tests/test_doc.py	2018-11-28 18:58:57 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """
@@ -8,27 +8,12 @@
 import os
 
 from lp.services.testing import build_test_suite
-from lp.testing.layers import (
-    DatabaseFunctionalLayer,
-    LaunchpadZopelessLayer,
-    )
-from lp.testing.systemdocs import (
-    LayeredDocFileSuite,
-    setUp,
-    tearDown,
-    )
+from lp.testing.layers import DatabaseFunctionalLayer
 
 
 here = os.path.dirname(os.path.realpath(__file__))
 
 
-special = {
-    'close-account.txt': LayeredDocFileSuite(
-    '../doc/close-account.txt', setUp=setUp, tearDown=tearDown,
-    layer=LaunchpadZopelessLayer),
-    }
-
-
 def test_suite():
-    suite = build_test_suite(here, special, layer=DatabaseFunctionalLayer)
+    suite = build_test_suite(here, layer=DatabaseFunctionalLayer)
     return suite

=== modified file 'scripts/close-account.py'
--- scripts/close-account.py	2018-09-12 11:53:08 +0000
+++ scripts/close-account.py	2018-11-28 18:58:57 +0000
@@ -5,219 +5,11 @@
 
 """Remove personal details of a user from the database, leaving a stub."""
 
-__metaclass__ = type
-__all__ = []
-
 import _pythonpath
 
-from optparse import OptionParser
-import sys
-
-from lp.answers.enums import QuestionStatus
-from lp.registry.interfaces.person import PersonCreationRationale
-from lp.services.database.sqlbase import (
-    connect,
-    sqlvalues,
-    )
-from lp.services.scripts import (
-    db_options,
-    logger,
-    logger_options,
-    )
-
-
-def close_account(con, log, username):
-    """Close a person's account.
-
-    Return True on success, or log an error message and return False
-    """
-    cur = con.cursor()
-    cur.execute("""
-        SELECT Person.id, Person.account, name, teamowner
-        FROM Person
-        LEFT OUTER JOIN EmailAddress ON Person.id = EmailAddress.person
-        WHERE name = %(username)s OR lower(email) = lower(%(username)s)
-        """, vars())
-    try:
-        person_id, account_id, username, teamowner = cur.fetchone()
-    except TypeError:
-        log.fatal("User %s does not exist" % username)
-        return False
-
-    # We don't do teams
-    if teamowner is not None:
-        log.fatal("%s is a team" % username)
-        return False
-
-    log.info("Closing %s's account" % username)
-
-    def table_notification(table):
-        log.debug("Handling the %s table" % table)
-
-    # All names starting with 'removed' are blacklisted, so this will always
-    # succeed.
-    new_name = 'removed%d' % person_id
-
-    # Remove the EmailAddress. This is the most important step, as
-    # people requesting account removal seem to primarily be interested
-    # in ensuring we no longer store this information.
-    table_notification('EmailAddress')
-    cur.execute("""
-        DELETE FROM EmailAddress WHERE person = %s
-        """ % sqlvalues(person_id))
-
-    # Clean out personal details from the Person table
-    table_notification('Person')
-    unknown_rationale = PersonCreationRationale.UNKNOWN.value
-    cur.execute("""
-        UPDATE Person
-        SET
-            displayname = 'Removed by request',
-            name=%(new_name)s,
-            language = NULL,
-            account = NULL,
-            homepage_content = NULL,
-            icon = NULL,
-            mugshot = NULL,
-            hide_email_addresses = TRUE,
-            registrant = NULL,
-            logo = NULL,
-            creation_rationale = %(unknown_rationale)s,
-            creation_comment = NULL
-        WHERE id = %(person_id)s
-        """, vars())
-
-    # Remove the Account. We don't set the status to deactivated,
-    # as this script is used to satisfy people who insist on us removing
-    # all their personal details from our systems. This includes any
-    # identification tokens like email addresses or openid identifiers.
-    # So the Account record would be unusable, and contain no useful
-    # information.
-    table_notification('Account')
-    if account_id is not None:
-        cur.execute("""
-            DELETE FROM Account WHERE id = %s
-            """ % sqlvalues(account_id))
-
-    # Reassign their bugs
-    table_notification('BugTask')
-    cur.execute("""
-        UPDATE BugTask SET assignee = NULL WHERE assignee = %(person_id)s
-        """, vars())
-
-    # Reassign questions assigned to the user, and close all their questions
-    # since nobody else can
-    table_notification('Question')
-    cur.execute("""
-        UPDATE Question SET assignee=NULL WHERE assignee=%(person_id)s
-        """, vars())
-    closed_question_status = QuestionStatus.SOLVED.value
-    cur.execute("""
-        UPDATE Question
-        SET status=%(closed_question_status)s, whiteboard=
-            'Closed by Launchpad due to owner requesting account removal'
-        WHERE owner=%(person_id)s
-        """, vars())
-
-    # Remove rows from tables in simple cases in the given order
-    removals = [
-        # Trash their email addresses. People who request complete account
-        # removal would be unhappy if they reregistered with their old email
-        # address and this resurrected their deleted account, as the email
-        # address is probably the piece of data we store that they were most
-        # concerned with being removed from our systems.
-        ('EmailAddress', 'person'),
-
-        # Trash their codes of conduct and GPG keys
-        ('SignedCodeOfConduct', 'owner'),
-        ('GpgKey', 'owner'),
-
-        # Subscriptions
-        ('BranchSubscription', 'person'),
-        ('GitSubscription', 'person'),
-        ('BugSubscription', 'person'),
-        ('QuestionSubscription', 'person'),
-        ('SpecificationSubscription', 'person'),
-
-        # Personal stuff, freeing up the namespace for others who want to play
-        # or just to remove any fingerprints identifying the user.
-        ('IrcId', 'person'),
-        ('JabberId', 'person'),
-        ('WikiName', 'person'),
-        ('PersonLanguage', 'person'),
-        ('PersonLocation', 'person'),
-        ('SshKey', 'person'),
-
-        # Karma
-        ('Karma', 'person'),
-        ('KarmaCache', 'person'),
-        ('KarmaTotalCache', 'person'),
-
-        # Team memberships
-        ('TeamMembership', 'person'),
-        ('TeamParticipation', 'person'),
-
-        # Contacts
-        ('AnswerContact', 'person'),
-
-        # Pending items in queues
-        ('POExportRequest', 'person'),
-
-        # Access grants
-        ('GitRuleGrant', 'grantee'),
-        ]
-    for table, person_id_column in removals:
-        table_notification(table)
-        cur.execute("""
-                DELETE FROM %(table)s WHERE %(person_id_column)s=%(person_id)d
-                """ % vars())
-
-    # Trash Sprint Attendance records in the future.
-    table_notification('SprintAttendance')
-    cur.execute("""
-        DELETE FROM SprintAttendance
-        USING Sprint
-        WHERE Sprint.id = SprintAttendance.sprint
-            AND attendee=%(person_id)s
-            AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
-        """, vars())
-
-    return True
-
-
-def main():
-    parser = OptionParser(
-            '%prog [options] (username|email) [...]'
-            )
-    db_options(parser)
-    logger_options(parser)
-
-    (options, args) = parser.parse_args()
-
-    if len(args) == 0:
-        parser.error("Must specify username (Person.name)")
-
-    log = logger(options)
-
-    con = None
-    try:
-        log.debug("Connecting to database")
-        con = connect()
-        for username in args:
-            if not close_account(con, log, username):
-                log.debug("Rolling back")
-                con.rollback()
-                return 1
-        log.debug("Committing changes")
-        con.commit()
-        return 0
-    except:
-        log.exception("Unhandled exception")
-        log.debug("Rolling back")
-        if con is not None:
-            con.rollback()
-        return 1
+from lp.registry.scripts.closeaccount import CloseAccountScript
 
 
 if __name__ == '__main__':
-    sys.exit(main())
+    script = CloseAccountScript('close-account', dbuser='launchpad')
+    script.run()


Follow ups