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

Proposed by Colin Watson
Status: Merged
Merged at revision: 18828
Proposed branch: lp:~cjwatson/launchpad/close-account-perms
Merge into: lp:launchpad
Diff against target: 684 lines (+314/-312)
6 files modified
database/schema/security.cfg (+2/-0)
lib/lp/registry/scripts/closeaccount.py (+193/-0)
lib/lp/registry/scripts/tests/test_closeaccount.py (+113/-0)
lib/lp/services/identity/doc/close-account.txt (+0/-83)
lib/lp/services/identity/tests/test_doc.py (+3/-18)
scripts/close-account.py (+3/-211)
To merge this branch: bzr merge lp:~cjwatson/launchpad/close-account-perms
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+359773@code.launchpad.net

Commit message

Make close-account work as a non-superuser.

Description of the change

I also Stormified and generally modernised close-account.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

I think we need to look over all Person FKs and work out what to do. The script has only been used a handful of times and then probably not for more than a decade, and it has weird stuff that bypasses audit trails, sets invalid questions to solved, wipes the audit trail in Account.status_comment, etc.

The port to Storm seems fine, but this can't go near production yet.

review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) wrote :

I've proposed https://code.launchpad.net/~cjwatson/launchpad/close-account-polish/+merge/359865 to try to make this safer and somewhat less weird.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== 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 @@
333[launchpad]333[launchpad]
334groups=launchpad_main334groups=launchpad_main
335type=user335type=user
336public.karma = SELECT, INSERT, UPDATE, DELETE
336public.sharingjob = SELECT, INSERT, UPDATE, DELETE337public.sharingjob = SELECT, INSERT, UPDATE, DELETE
338public.signedcodeofconduct = SELECT, INSERT, UPDATE, DELETE
337339
338[script]340[script]
339public.accessartifact = SELECT341public.accessartifact = SELECT
340342
=== 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 @@
1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Remove personal details of a user from the database, leaving a stub."""
5
6__metaclass__ = type
7__all__ = ['CloseAccountScript']
8
9from storm.expr import (
10 LeftJoin,
11 Lower,
12 Or,
13 )
14
15from lp.answers.enums import QuestionStatus
16from lp.answers.model.question import Question
17from lp.bugs.model.bugtask import BugTask
18from lp.registry.interfaces.person import PersonCreationRationale
19from lp.registry.model.person import Person
20from lp.services.database.interfaces import IMasterStore
21from lp.services.identity.model.account import Account
22from lp.services.identity.model.emailaddress import EmailAddress
23from lp.services.scripts.base import (
24 LaunchpadScript,
25 LaunchpadScriptFailure,
26 )
27
28
29def close_account(username, log):
30 """Close a person's account.
31
32 Return True on success, or log an error message and return False
33 """
34 store = IMasterStore(Person)
35
36 row = store.using(
37 Person,
38 LeftJoin(EmailAddress, Person.id == EmailAddress.personID)
39 ).find(
40 (Person.id, Person.accountID, Person.name, Person.teamownerID),
41 Or(Person.name == username,
42 Lower(EmailAddress.email) == Lower(username))).one()
43 if row is None:
44 raise LaunchpadScriptFailure("User %s does not exist" % username)
45 person_id, account_id, username, teamowner_id = row
46
47 # We don't do teams
48 if teamowner_id is not None:
49 raise LaunchpadScriptFailure("%s is a team" % username)
50
51 log.info("Closing %s's account" % username)
52
53 def table_notification(table):
54 log.debug("Handling the %s table" % table)
55
56 # All names starting with 'removed' are blacklisted, so this will always
57 # succeed.
58 new_name = 'removed%d' % person_id
59
60 # Remove the EmailAddress. This is the most important step, as
61 # people requesting account removal seem to primarily be interested
62 # in ensuring we no longer store this information.
63 table_notification('EmailAddress')
64 store.find(EmailAddress, EmailAddress.personID == person_id).remove()
65
66 # Clean out personal details from the Person table
67 table_notification('Person')
68 store.find(Person, Person.id == person_id).set(
69 display_name='Removed by request',
70 name=new_name,
71 accountID=None,
72 homepage_content=None,
73 iconID=None,
74 mugshotID=None,
75 hide_email_addresses=True,
76 registrantID=None,
77 logoID=None,
78 creation_rationale=PersonCreationRationale.UNKNOWN,
79 creation_comment=None)
80
81 # Remove the Account. We don't set the status to deactivated,
82 # as this script is used to satisfy people who insist on us removing
83 # all their personal details from our systems. This includes any
84 # identification tokens like email addresses or openid identifiers.
85 # So the Account record would be unusable, and contain no useful
86 # information.
87 table_notification('Account')
88 if account_id is not None:
89 store.find(Account, Account.id == account_id).remove()
90
91 # Reassign their bugs
92 table_notification('BugTask')
93 store.find(BugTask, BugTask.assigneeID == person_id).set(assigneeID=None)
94
95 # Reassign questions assigned to the user, and close all their questions
96 # since nobody else can
97 table_notification('Question')
98 store.find(Question, Question.assigneeID == person_id).set(assigneeID=None)
99 store.find(Question, Question.ownerID == person_id).set(
100 status=QuestionStatus.SOLVED,
101 whiteboard=(
102 'Closed by Launchpad due to owner requesting account removal'))
103
104 # Remove rows from tables in simple cases in the given order
105 removals = [
106 # Trash their email addresses. People who request complete account
107 # removal would be unhappy if they reregistered with their old email
108 # address and this resurrected their deleted account, as the email
109 # address is probably the piece of data we store that they were most
110 # concerned with being removed from our systems.
111 ('EmailAddress', 'person'),
112
113 # Trash their codes of conduct and GPG keys
114 ('SignedCodeOfConduct', 'owner'),
115 ('GpgKey', 'owner'),
116
117 # Subscriptions
118 ('BranchSubscription', 'person'),
119 ('GitSubscription', 'person'),
120 ('BugSubscription', 'person'),
121 ('QuestionSubscription', 'person'),
122 ('SpecificationSubscription', 'person'),
123
124 # Personal stuff, freeing up the namespace for others who want to play
125 # or just to remove any fingerprints identifying the user.
126 ('IrcId', 'person'),
127 ('JabberId', 'person'),
128 ('WikiName', 'person'),
129 ('PersonLanguage', 'person'),
130 ('PersonLocation', 'person'),
131 ('SshKey', 'person'),
132
133 # Karma
134 ('Karma', 'person'),
135 ('KarmaCache', 'person'),
136 ('KarmaTotalCache', 'person'),
137
138 # Team memberships
139 ('TeamMembership', 'person'),
140 ('TeamParticipation', 'person'),
141
142 # Contacts
143 ('AnswerContact', 'person'),
144
145 # Pending items in queues
146 ('POExportRequest', 'person'),
147
148 # Access grants
149 ('GitRuleGrant', 'grantee'),
150 ]
151 for table, person_id_column in removals:
152 table_notification(table)
153 store.execute("""
154 DELETE FROM %(table)s WHERE %(person_id_column)s = ?
155 """ % {
156 'table': table,
157 'person_id_column': person_id_column,
158 },
159 (person_id,))
160
161 # Trash Sprint Attendance records in the future.
162 table_notification('SprintAttendance')
163 store.execute("""
164 DELETE FROM SprintAttendance
165 USING Sprint
166 WHERE Sprint.id = SprintAttendance.sprint
167 AND attendee = ?
168 AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
169 """, (person_id,))
170
171 return True
172
173
174class CloseAccountScript(LaunchpadScript):
175
176 usage = '%prog [options] (username|email) [...]'
177 description = (
178 "Close a person's account, deleting as much personal information "
179 "as possible.")
180
181 def main(self):
182 if not self.args:
183 raise LaunchpadScriptFailure(
184 "Must specify username (Person.name)")
185
186 for username in self.args:
187 try:
188 close_account(unicode(username), self.logger)
189 except Exception:
190 self.txn.abort()
191 raise
192 self.logger.debug("Committing changes")
193 self.txn.commit()
0194
=== 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 @@
1# Copyright 2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test the close-account script."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10from zope.component import getUtility
11from zope.security.proxy import removeSecurityProxy
12
13from lp.registry.interfaces.person import IPersonSet
14from lp.registry.scripts.closeaccount import CloseAccountScript
15from lp.services.identity.interfaces.account import (
16 AccountStatus,
17 IAccountSet,
18 )
19from lp.services.log.logger import DevNullLogger
20from lp.services.scripts.base import LaunchpadScriptFailure
21from lp.testing import TestCaseWithFactory
22from lp.testing.dbuser import dbuser
23from lp.testing.faketransaction import FakeTransaction
24from lp.testing.layers import ZopelessDatabaseLayer
25
26
27class TestCloseAccount(TestCaseWithFactory):
28 """Test the close-account script.
29
30 Unfortunately, we have no way of detecting schema updates containing new
31 information that needs to be removed or sanitized on account closure
32 apart from reviewers noticing and prompting developers to update this
33 script. See Bug #120506 for more details.
34 """
35
36 layer = ZopelessDatabaseLayer
37
38 def makeScript(self, test_args):
39 script = CloseAccountScript(test_args=test_args)
40 script.logger = DevNullLogger()
41 script.txn = FakeTransaction()
42 return script
43
44 def getSampleUser(self, name, email):
45 """Return a sampledata account with some personal information."""
46 person = getUtility(IPersonSet).getByEmail(email)
47 account = removeSecurityProxy(person.account)
48 self.assertEqual(AccountStatus.ACTIVE, account.status)
49 self.assertEqual(name, person.name)
50 return person.id, account.id
51
52 def assertRemoved(self, account_id, person_id):
53 # We can't just set the account to DEACTIVATED, as the
54 # close-account.py script is used to satisfy people who insist on us
55 # removing all their personal details from our system. The Account
56 # has been removed entirely.
57 self.assertRaises(
58 LookupError, getUtility(IAccountSet).get, account_id)
59
60 # The Person record still exists to maintain links with information
61 # that won't be removed, such as bug comments, but has been
62 # anonymized.
63 person = getUtility(IPersonSet).get(person_id)
64 self.assertStartsWith(person.name, 'removed')
65 self.assertEqual('Removed by request', person.display_name)
66
67 def assertNotRemoved(self, account_id, person_id):
68 account = getUtility(IAccountSet).get(account_id)
69 self.assertEqual(AccountStatus.ACTIVE, account.status)
70 person = getUtility(IPersonSet).get(person_id)
71 self.assertIsNotNone(person.account)
72
73 def test_nonexistent(self):
74 script = self.makeScript(['nonexistent-person'])
75 with dbuser('launchpad'):
76 self.assertRaisesWithContent(
77 LaunchpadScriptFailure,
78 'User nonexistent-person does not exist',
79 script.main)
80
81 def test_team(self):
82 team = self.factory.makeTeam()
83 script = self.makeScript([team.name])
84 with dbuser('launchpad'):
85 self.assertRaisesWithContent(
86 LaunchpadScriptFailure,
87 '%s is a team' % team.name,
88 script.main)
89
90 def test_single_by_name(self):
91 person_id, account_id = self.getSampleUser('mark', 'mark@example.com')
92 script = self.makeScript(['mark'])
93 with dbuser('launchpad'):
94 script.main()
95 self.assertRemoved(account_id, person_id)
96
97 def test_single_by_email(self):
98 person_id, account_id = self.getSampleUser('mark', 'mark@example.com')
99 script = self.makeScript(['mark@example.com'])
100 with dbuser('launchpad'):
101 script.main()
102 self.assertRemoved(account_id, person_id)
103
104 def test_multiple(self):
105 persons = [self.factory.makePerson() for _ in range(3)]
106 person_ids = [person.id for person in persons]
107 account_ids = [person.account.id for person in persons]
108 script = self.makeScript([persons[0].name, persons[1].name])
109 with dbuser('launchpad'):
110 script.main()
111 self.assertRemoved(account_ids[0], person_ids[0])
112 self.assertRemoved(account_ids[1], person_ids[1])
113 self.assertNotRemoved(account_ids[2], person_ids[2])
0114
=== 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 @@
1We have a command line script that can be used to close accounts.
2
3Unfortunately, we have no way of detecting schema updates containing new
4information that needs to be removed or sanitized on account closure apart
5from reviewers noticing and prompting developers to update this script.
6
7See Bug #120506 for more details.
8
9
10Get Mark's account and person entries.
11>>> from lp.registry.interfaces.person import IPersonSet
12>>> from zope.security.proxy import removeSecurityProxy
13>>> mark_person = getUtility(IPersonSet).getByEmail('mark@example.com')
14>>> mark_account = removeSecurityProxy(mark_person.account)
15
16
17Mark's account is active and contains personal information.
18
19>>> print mark_account.status.name
20ACTIVE
21>>> print mark_account.displayname
22Mark Shuttleworth
23>>> print mark_person.name
24mark
25>>> print mark_person.displayname
26Mark Shuttleworth
27
28
29Store the id's so we can retrieve the records later.
30
31>>> mark_person_id = mark_person.id
32>>> mark_account_id = mark_account.id
33
34
35Lets close his account.
36
37>>> import os.path
38>>> from lp.services.config import config
39>>> from lp.testing.script import run_script
40>>> script = os.path.join(config.root, 'scripts', 'close-account.py')
41>>> (result, out, err) = run_script(script, args=['mark@example.com'])
42>>> print result
430
44>>> print out
45>>> print err
46INFO...Closing mark's account
47
48
49Now, start a new transaction so we can see the changes the script made.
50
51>>> from lp.testing.layers import LaunchpadZopelessLayer
52>>> LaunchpadZopelessLayer.abort()
53
54
55We can't just set the account to DEACTIVATED, as the close-account.py
56script is used to satisty people who insist on us removing all their
57personal details from our system. The Account has been removed entirely.
58
59>>> from lp.services.identity.model.account import Account
60>>> Account.get(mark_account_id)
61Traceback (most recent call last):
62...
63SQLObjectNotFound: ...
64
65
66The Person record still exists to maintain links with information that won't
67be removed, such as bug comments, but has been anonymized.
68
69>>> from lp.registry.model.person import Person
70>>> mark_person = Person.get(mark_person_id)
71>>> print mark_person.name
72removed...
73>>> print mark_person.displayname
74Removed by request
75
76
77Flag the database as dirty since it has been modified without the test suite
78knowing.
79
80>>> from lp.testing.layers import DatabaseLayer
81>>> DatabaseLayer.force_dirty_database()
82
83
840
=== 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 @@
1# Copyright 2009-2011 Canonical Ltd. This software is licensed under the1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""4"""
@@ -8,27 +8,12 @@
8import os8import os
99
10from lp.services.testing import build_test_suite10from lp.services.testing import build_test_suite
11from lp.testing.layers import (11from lp.testing.layers import DatabaseFunctionalLayer
12 DatabaseFunctionalLayer,
13 LaunchpadZopelessLayer,
14 )
15from lp.testing.systemdocs import (
16 LayeredDocFileSuite,
17 setUp,
18 tearDown,
19 )
2012
2113
22here = os.path.dirname(os.path.realpath(__file__))14here = os.path.dirname(os.path.realpath(__file__))
2315
2416
25special = {
26 'close-account.txt': LayeredDocFileSuite(
27 '../doc/close-account.txt', setUp=setUp, tearDown=tearDown,
28 layer=LaunchpadZopelessLayer),
29 }
30
31
32def test_suite():17def test_suite():
33 suite = build_test_suite(here, special, layer=DatabaseFunctionalLayer)18 suite = build_test_suite(here, layer=DatabaseFunctionalLayer)
34 return suite19 return suite
3520
=== 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 @@
55
6"""Remove personal details of a user from the database, leaving a stub."""6"""Remove personal details of a user from the database, leaving a stub."""
77
8__metaclass__ = type
9__all__ = []
10
11import _pythonpath8import _pythonpath
129
13from optparse import OptionParser10from lp.registry.scripts.closeaccount import CloseAccountScript
14import sys
15
16from lp.answers.enums import QuestionStatus
17from lp.registry.interfaces.person import PersonCreationRationale
18from lp.services.database.sqlbase import (
19 connect,
20 sqlvalues,
21 )
22from lp.services.scripts import (
23 db_options,
24 logger,
25 logger_options,
26 )
27
28
29def close_account(con, log, username):
30 """Close a person's account.
31
32 Return True on success, or log an error message and return False
33 """
34 cur = con.cursor()
35 cur.execute("""
36 SELECT Person.id, Person.account, name, teamowner
37 FROM Person
38 LEFT OUTER JOIN EmailAddress ON Person.id = EmailAddress.person
39 WHERE name = %(username)s OR lower(email) = lower(%(username)s)
40 """, vars())
41 try:
42 person_id, account_id, username, teamowner = cur.fetchone()
43 except TypeError:
44 log.fatal("User %s does not exist" % username)
45 return False
46
47 # We don't do teams
48 if teamowner is not None:
49 log.fatal("%s is a team" % username)
50 return False
51
52 log.info("Closing %s's account" % username)
53
54 def table_notification(table):
55 log.debug("Handling the %s table" % table)
56
57 # All names starting with 'removed' are blacklisted, so this will always
58 # succeed.
59 new_name = 'removed%d' % person_id
60
61 # Remove the EmailAddress. This is the most important step, as
62 # people requesting account removal seem to primarily be interested
63 # in ensuring we no longer store this information.
64 table_notification('EmailAddress')
65 cur.execute("""
66 DELETE FROM EmailAddress WHERE person = %s
67 """ % sqlvalues(person_id))
68
69 # Clean out personal details from the Person table
70 table_notification('Person')
71 unknown_rationale = PersonCreationRationale.UNKNOWN.value
72 cur.execute("""
73 UPDATE Person
74 SET
75 displayname = 'Removed by request',
76 name=%(new_name)s,
77 language = NULL,
78 account = NULL,
79 homepage_content = NULL,
80 icon = NULL,
81 mugshot = NULL,
82 hide_email_addresses = TRUE,
83 registrant = NULL,
84 logo = NULL,
85 creation_rationale = %(unknown_rationale)s,
86 creation_comment = NULL
87 WHERE id = %(person_id)s
88 """, vars())
89
90 # Remove the Account. We don't set the status to deactivated,
91 # as this script is used to satisfy people who insist on us removing
92 # all their personal details from our systems. This includes any
93 # identification tokens like email addresses or openid identifiers.
94 # So the Account record would be unusable, and contain no useful
95 # information.
96 table_notification('Account')
97 if account_id is not None:
98 cur.execute("""
99 DELETE FROM Account WHERE id = %s
100 """ % sqlvalues(account_id))
101
102 # Reassign their bugs
103 table_notification('BugTask')
104 cur.execute("""
105 UPDATE BugTask SET assignee = NULL WHERE assignee = %(person_id)s
106 """, vars())
107
108 # Reassign questions assigned to the user, and close all their questions
109 # since nobody else can
110 table_notification('Question')
111 cur.execute("""
112 UPDATE Question SET assignee=NULL WHERE assignee=%(person_id)s
113 """, vars())
114 closed_question_status = QuestionStatus.SOLVED.value
115 cur.execute("""
116 UPDATE Question
117 SET status=%(closed_question_status)s, whiteboard=
118 'Closed by Launchpad due to owner requesting account removal'
119 WHERE owner=%(person_id)s
120 """, vars())
121
122 # Remove rows from tables in simple cases in the given order
123 removals = [
124 # Trash their email addresses. People who request complete account
125 # removal would be unhappy if they reregistered with their old email
126 # address and this resurrected their deleted account, as the email
127 # address is probably the piece of data we store that they were most
128 # concerned with being removed from our systems.
129 ('EmailAddress', 'person'),
130
131 # Trash their codes of conduct and GPG keys
132 ('SignedCodeOfConduct', 'owner'),
133 ('GpgKey', 'owner'),
134
135 # Subscriptions
136 ('BranchSubscription', 'person'),
137 ('GitSubscription', 'person'),
138 ('BugSubscription', 'person'),
139 ('QuestionSubscription', 'person'),
140 ('SpecificationSubscription', 'person'),
141
142 # Personal stuff, freeing up the namespace for others who want to play
143 # or just to remove any fingerprints identifying the user.
144 ('IrcId', 'person'),
145 ('JabberId', 'person'),
146 ('WikiName', 'person'),
147 ('PersonLanguage', 'person'),
148 ('PersonLocation', 'person'),
149 ('SshKey', 'person'),
150
151 # Karma
152 ('Karma', 'person'),
153 ('KarmaCache', 'person'),
154 ('KarmaTotalCache', 'person'),
155
156 # Team memberships
157 ('TeamMembership', 'person'),
158 ('TeamParticipation', 'person'),
159
160 # Contacts
161 ('AnswerContact', 'person'),
162
163 # Pending items in queues
164 ('POExportRequest', 'person'),
165
166 # Access grants
167 ('GitRuleGrant', 'grantee'),
168 ]
169 for table, person_id_column in removals:
170 table_notification(table)
171 cur.execute("""
172 DELETE FROM %(table)s WHERE %(person_id_column)s=%(person_id)d
173 """ % vars())
174
175 # Trash Sprint Attendance records in the future.
176 table_notification('SprintAttendance')
177 cur.execute("""
178 DELETE FROM SprintAttendance
179 USING Sprint
180 WHERE Sprint.id = SprintAttendance.sprint
181 AND attendee=%(person_id)s
182 AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
183 """, vars())
184
185 return True
186
187
188def main():
189 parser = OptionParser(
190 '%prog [options] (username|email) [...]'
191 )
192 db_options(parser)
193 logger_options(parser)
194
195 (options, args) = parser.parse_args()
196
197 if len(args) == 0:
198 parser.error("Must specify username (Person.name)")
199
200 log = logger(options)
201
202 con = None
203 try:
204 log.debug("Connecting to database")
205 con = connect()
206 for username in args:
207 if not close_account(con, log, username):
208 log.debug("Rolling back")
209 con.rollback()
210 return 1
211 log.debug("Committing changes")
212 con.commit()
213 return 0
214 except:
215 log.exception("Unhandled exception")
216 log.debug("Rolling back")
217 if con is not None:
218 con.rollback()
219 return 1
22011
22112
222if __name__ == '__main__':13if __name__ == '__main__':
223 sys.exit(main())14 script = CloseAccountScript('close-account', dbuser='launchpad')
15 script.run()