Merge lp:~cjwatson/launchpad/close-account-perms into lp:launchpad
- close-account-perms
- Merge into devel
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 |
Related bugs: |
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
Colin Watson (cjwatson) wrote : | # |
I've proposed https:/
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'database/schema/security.cfg' | |||
2 | --- database/schema/security.cfg 2018-10-16 10:10:10 +0000 | |||
3 | +++ database/schema/security.cfg 2018-11-28 18:58:57 +0000 | |||
4 | @@ -333,7 +333,9 @@ | |||
5 | 333 | [launchpad] | 333 | [launchpad] |
6 | 334 | groups=launchpad_main | 334 | groups=launchpad_main |
7 | 335 | type=user | 335 | type=user |
8 | 336 | public.karma = SELECT, INSERT, UPDATE, DELETE | ||
9 | 336 | public.sharingjob = SELECT, INSERT, UPDATE, DELETE | 337 | public.sharingjob = SELECT, INSERT, UPDATE, DELETE |
10 | 338 | public.signedcodeofconduct = SELECT, INSERT, UPDATE, DELETE | ||
11 | 337 | 339 | ||
12 | 338 | [script] | 340 | [script] |
13 | 339 | public.accessartifact = SELECT | 341 | public.accessartifact = SELECT |
14 | 340 | 342 | ||
15 | === added file 'lib/lp/registry/scripts/closeaccount.py' | |||
16 | --- lib/lp/registry/scripts/closeaccount.py 1970-01-01 00:00:00 +0000 | |||
17 | +++ lib/lp/registry/scripts/closeaccount.py 2018-11-28 18:58:57 +0000 | |||
18 | @@ -0,0 +1,193 @@ | |||
19 | 1 | # Copyright 2009-2018 Canonical Ltd. This software is licensed under the | ||
20 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
21 | 3 | |||
22 | 4 | """Remove personal details of a user from the database, leaving a stub.""" | ||
23 | 5 | |||
24 | 6 | __metaclass__ = type | ||
25 | 7 | __all__ = ['CloseAccountScript'] | ||
26 | 8 | |||
27 | 9 | from storm.expr import ( | ||
28 | 10 | LeftJoin, | ||
29 | 11 | Lower, | ||
30 | 12 | Or, | ||
31 | 13 | ) | ||
32 | 14 | |||
33 | 15 | from lp.answers.enums import QuestionStatus | ||
34 | 16 | from lp.answers.model.question import Question | ||
35 | 17 | from lp.bugs.model.bugtask import BugTask | ||
36 | 18 | from lp.registry.interfaces.person import PersonCreationRationale | ||
37 | 19 | from lp.registry.model.person import Person | ||
38 | 20 | from lp.services.database.interfaces import IMasterStore | ||
39 | 21 | from lp.services.identity.model.account import Account | ||
40 | 22 | from lp.services.identity.model.emailaddress import EmailAddress | ||
41 | 23 | from lp.services.scripts.base import ( | ||
42 | 24 | LaunchpadScript, | ||
43 | 25 | LaunchpadScriptFailure, | ||
44 | 26 | ) | ||
45 | 27 | |||
46 | 28 | |||
47 | 29 | def close_account(username, log): | ||
48 | 30 | """Close a person's account. | ||
49 | 31 | |||
50 | 32 | Return True on success, or log an error message and return False | ||
51 | 33 | """ | ||
52 | 34 | store = IMasterStore(Person) | ||
53 | 35 | |||
54 | 36 | row = store.using( | ||
55 | 37 | Person, | ||
56 | 38 | LeftJoin(EmailAddress, Person.id == EmailAddress.personID) | ||
57 | 39 | ).find( | ||
58 | 40 | (Person.id, Person.accountID, Person.name, Person.teamownerID), | ||
59 | 41 | Or(Person.name == username, | ||
60 | 42 | Lower(EmailAddress.email) == Lower(username))).one() | ||
61 | 43 | if row is None: | ||
62 | 44 | raise LaunchpadScriptFailure("User %s does not exist" % username) | ||
63 | 45 | person_id, account_id, username, teamowner_id = row | ||
64 | 46 | |||
65 | 47 | # We don't do teams | ||
66 | 48 | if teamowner_id is not None: | ||
67 | 49 | raise LaunchpadScriptFailure("%s is a team" % username) | ||
68 | 50 | |||
69 | 51 | log.info("Closing %s's account" % username) | ||
70 | 52 | |||
71 | 53 | def table_notification(table): | ||
72 | 54 | log.debug("Handling the %s table" % table) | ||
73 | 55 | |||
74 | 56 | # All names starting with 'removed' are blacklisted, so this will always | ||
75 | 57 | # succeed. | ||
76 | 58 | new_name = 'removed%d' % person_id | ||
77 | 59 | |||
78 | 60 | # Remove the EmailAddress. This is the most important step, as | ||
79 | 61 | # people requesting account removal seem to primarily be interested | ||
80 | 62 | # in ensuring we no longer store this information. | ||
81 | 63 | table_notification('EmailAddress') | ||
82 | 64 | store.find(EmailAddress, EmailAddress.personID == person_id).remove() | ||
83 | 65 | |||
84 | 66 | # Clean out personal details from the Person table | ||
85 | 67 | table_notification('Person') | ||
86 | 68 | store.find(Person, Person.id == person_id).set( | ||
87 | 69 | display_name='Removed by request', | ||
88 | 70 | name=new_name, | ||
89 | 71 | accountID=None, | ||
90 | 72 | homepage_content=None, | ||
91 | 73 | iconID=None, | ||
92 | 74 | mugshotID=None, | ||
93 | 75 | hide_email_addresses=True, | ||
94 | 76 | registrantID=None, | ||
95 | 77 | logoID=None, | ||
96 | 78 | creation_rationale=PersonCreationRationale.UNKNOWN, | ||
97 | 79 | creation_comment=None) | ||
98 | 80 | |||
99 | 81 | # Remove the Account. We don't set the status to deactivated, | ||
100 | 82 | # as this script is used to satisfy people who insist on us removing | ||
101 | 83 | # all their personal details from our systems. This includes any | ||
102 | 84 | # identification tokens like email addresses or openid identifiers. | ||
103 | 85 | # So the Account record would be unusable, and contain no useful | ||
104 | 86 | # information. | ||
105 | 87 | table_notification('Account') | ||
106 | 88 | if account_id is not None: | ||
107 | 89 | store.find(Account, Account.id == account_id).remove() | ||
108 | 90 | |||
109 | 91 | # Reassign their bugs | ||
110 | 92 | table_notification('BugTask') | ||
111 | 93 | store.find(BugTask, BugTask.assigneeID == person_id).set(assigneeID=None) | ||
112 | 94 | |||
113 | 95 | # Reassign questions assigned to the user, and close all their questions | ||
114 | 96 | # since nobody else can | ||
115 | 97 | table_notification('Question') | ||
116 | 98 | store.find(Question, Question.assigneeID == person_id).set(assigneeID=None) | ||
117 | 99 | store.find(Question, Question.ownerID == person_id).set( | ||
118 | 100 | status=QuestionStatus.SOLVED, | ||
119 | 101 | whiteboard=( | ||
120 | 102 | 'Closed by Launchpad due to owner requesting account removal')) | ||
121 | 103 | |||
122 | 104 | # Remove rows from tables in simple cases in the given order | ||
123 | 105 | removals = [ | ||
124 | 106 | # Trash their email addresses. People who request complete account | ||
125 | 107 | # removal would be unhappy if they reregistered with their old email | ||
126 | 108 | # address and this resurrected their deleted account, as the email | ||
127 | 109 | # address is probably the piece of data we store that they were most | ||
128 | 110 | # concerned with being removed from our systems. | ||
129 | 111 | ('EmailAddress', 'person'), | ||
130 | 112 | |||
131 | 113 | # Trash their codes of conduct and GPG keys | ||
132 | 114 | ('SignedCodeOfConduct', 'owner'), | ||
133 | 115 | ('GpgKey', 'owner'), | ||
134 | 116 | |||
135 | 117 | # Subscriptions | ||
136 | 118 | ('BranchSubscription', 'person'), | ||
137 | 119 | ('GitSubscription', 'person'), | ||
138 | 120 | ('BugSubscription', 'person'), | ||
139 | 121 | ('QuestionSubscription', 'person'), | ||
140 | 122 | ('SpecificationSubscription', 'person'), | ||
141 | 123 | |||
142 | 124 | # Personal stuff, freeing up the namespace for others who want to play | ||
143 | 125 | # or just to remove any fingerprints identifying the user. | ||
144 | 126 | ('IrcId', 'person'), | ||
145 | 127 | ('JabberId', 'person'), | ||
146 | 128 | ('WikiName', 'person'), | ||
147 | 129 | ('PersonLanguage', 'person'), | ||
148 | 130 | ('PersonLocation', 'person'), | ||
149 | 131 | ('SshKey', 'person'), | ||
150 | 132 | |||
151 | 133 | # Karma | ||
152 | 134 | ('Karma', 'person'), | ||
153 | 135 | ('KarmaCache', 'person'), | ||
154 | 136 | ('KarmaTotalCache', 'person'), | ||
155 | 137 | |||
156 | 138 | # Team memberships | ||
157 | 139 | ('TeamMembership', 'person'), | ||
158 | 140 | ('TeamParticipation', 'person'), | ||
159 | 141 | |||
160 | 142 | # Contacts | ||
161 | 143 | ('AnswerContact', 'person'), | ||
162 | 144 | |||
163 | 145 | # Pending items in queues | ||
164 | 146 | ('POExportRequest', 'person'), | ||
165 | 147 | |||
166 | 148 | # Access grants | ||
167 | 149 | ('GitRuleGrant', 'grantee'), | ||
168 | 150 | ] | ||
169 | 151 | for table, person_id_column in removals: | ||
170 | 152 | table_notification(table) | ||
171 | 153 | store.execute(""" | ||
172 | 154 | DELETE FROM %(table)s WHERE %(person_id_column)s = ? | ||
173 | 155 | """ % { | ||
174 | 156 | 'table': table, | ||
175 | 157 | 'person_id_column': person_id_column, | ||
176 | 158 | }, | ||
177 | 159 | (person_id,)) | ||
178 | 160 | |||
179 | 161 | # Trash Sprint Attendance records in the future. | ||
180 | 162 | table_notification('SprintAttendance') | ||
181 | 163 | store.execute(""" | ||
182 | 164 | DELETE FROM SprintAttendance | ||
183 | 165 | USING Sprint | ||
184 | 166 | WHERE Sprint.id = SprintAttendance.sprint | ||
185 | 167 | AND attendee = ? | ||
186 | 168 | AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC' | ||
187 | 169 | """, (person_id,)) | ||
188 | 170 | |||
189 | 171 | return True | ||
190 | 172 | |||
191 | 173 | |||
192 | 174 | class CloseAccountScript(LaunchpadScript): | ||
193 | 175 | |||
194 | 176 | usage = '%prog [options] (username|email) [...]' | ||
195 | 177 | description = ( | ||
196 | 178 | "Close a person's account, deleting as much personal information " | ||
197 | 179 | "as possible.") | ||
198 | 180 | |||
199 | 181 | def main(self): | ||
200 | 182 | if not self.args: | ||
201 | 183 | raise LaunchpadScriptFailure( | ||
202 | 184 | "Must specify username (Person.name)") | ||
203 | 185 | |||
204 | 186 | for username in self.args: | ||
205 | 187 | try: | ||
206 | 188 | close_account(unicode(username), self.logger) | ||
207 | 189 | except Exception: | ||
208 | 190 | self.txn.abort() | ||
209 | 191 | raise | ||
210 | 192 | self.logger.debug("Committing changes") | ||
211 | 193 | self.txn.commit() | ||
212 | 0 | 194 | ||
213 | === added file 'lib/lp/registry/scripts/tests/test_closeaccount.py' | |||
214 | --- lib/lp/registry/scripts/tests/test_closeaccount.py 1970-01-01 00:00:00 +0000 | |||
215 | +++ lib/lp/registry/scripts/tests/test_closeaccount.py 2018-11-28 18:58:57 +0000 | |||
216 | @@ -0,0 +1,113 @@ | |||
217 | 1 | # Copyright 2018 Canonical Ltd. This software is licensed under the | ||
218 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
219 | 3 | |||
220 | 4 | """Test the close-account script.""" | ||
221 | 5 | |||
222 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
223 | 7 | |||
224 | 8 | __metaclass__ = type | ||
225 | 9 | |||
226 | 10 | from zope.component import getUtility | ||
227 | 11 | from zope.security.proxy import removeSecurityProxy | ||
228 | 12 | |||
229 | 13 | from lp.registry.interfaces.person import IPersonSet | ||
230 | 14 | from lp.registry.scripts.closeaccount import CloseAccountScript | ||
231 | 15 | from lp.services.identity.interfaces.account import ( | ||
232 | 16 | AccountStatus, | ||
233 | 17 | IAccountSet, | ||
234 | 18 | ) | ||
235 | 19 | from lp.services.log.logger import DevNullLogger | ||
236 | 20 | from lp.services.scripts.base import LaunchpadScriptFailure | ||
237 | 21 | from lp.testing import TestCaseWithFactory | ||
238 | 22 | from lp.testing.dbuser import dbuser | ||
239 | 23 | from lp.testing.faketransaction import FakeTransaction | ||
240 | 24 | from lp.testing.layers import ZopelessDatabaseLayer | ||
241 | 25 | |||
242 | 26 | |||
243 | 27 | class TestCloseAccount(TestCaseWithFactory): | ||
244 | 28 | """Test the close-account script. | ||
245 | 29 | |||
246 | 30 | Unfortunately, we have no way of detecting schema updates containing new | ||
247 | 31 | information that needs to be removed or sanitized on account closure | ||
248 | 32 | apart from reviewers noticing and prompting developers to update this | ||
249 | 33 | script. See Bug #120506 for more details. | ||
250 | 34 | """ | ||
251 | 35 | |||
252 | 36 | layer = ZopelessDatabaseLayer | ||
253 | 37 | |||
254 | 38 | def makeScript(self, test_args): | ||
255 | 39 | script = CloseAccountScript(test_args=test_args) | ||
256 | 40 | script.logger = DevNullLogger() | ||
257 | 41 | script.txn = FakeTransaction() | ||
258 | 42 | return script | ||
259 | 43 | |||
260 | 44 | def getSampleUser(self, name, email): | ||
261 | 45 | """Return a sampledata account with some personal information.""" | ||
262 | 46 | person = getUtility(IPersonSet).getByEmail(email) | ||
263 | 47 | account = removeSecurityProxy(person.account) | ||
264 | 48 | self.assertEqual(AccountStatus.ACTIVE, account.status) | ||
265 | 49 | self.assertEqual(name, person.name) | ||
266 | 50 | return person.id, account.id | ||
267 | 51 | |||
268 | 52 | def assertRemoved(self, account_id, person_id): | ||
269 | 53 | # We can't just set the account to DEACTIVATED, as the | ||
270 | 54 | # close-account.py script is used to satisfy people who insist on us | ||
271 | 55 | # removing all their personal details from our system. The Account | ||
272 | 56 | # has been removed entirely. | ||
273 | 57 | self.assertRaises( | ||
274 | 58 | LookupError, getUtility(IAccountSet).get, account_id) | ||
275 | 59 | |||
276 | 60 | # The Person record still exists to maintain links with information | ||
277 | 61 | # that won't be removed, such as bug comments, but has been | ||
278 | 62 | # anonymized. | ||
279 | 63 | person = getUtility(IPersonSet).get(person_id) | ||
280 | 64 | self.assertStartsWith(person.name, 'removed') | ||
281 | 65 | self.assertEqual('Removed by request', person.display_name) | ||
282 | 66 | |||
283 | 67 | def assertNotRemoved(self, account_id, person_id): | ||
284 | 68 | account = getUtility(IAccountSet).get(account_id) | ||
285 | 69 | self.assertEqual(AccountStatus.ACTIVE, account.status) | ||
286 | 70 | person = getUtility(IPersonSet).get(person_id) | ||
287 | 71 | self.assertIsNotNone(person.account) | ||
288 | 72 | |||
289 | 73 | def test_nonexistent(self): | ||
290 | 74 | script = self.makeScript(['nonexistent-person']) | ||
291 | 75 | with dbuser('launchpad'): | ||
292 | 76 | self.assertRaisesWithContent( | ||
293 | 77 | LaunchpadScriptFailure, | ||
294 | 78 | 'User nonexistent-person does not exist', | ||
295 | 79 | script.main) | ||
296 | 80 | |||
297 | 81 | def test_team(self): | ||
298 | 82 | team = self.factory.makeTeam() | ||
299 | 83 | script = self.makeScript([team.name]) | ||
300 | 84 | with dbuser('launchpad'): | ||
301 | 85 | self.assertRaisesWithContent( | ||
302 | 86 | LaunchpadScriptFailure, | ||
303 | 87 | '%s is a team' % team.name, | ||
304 | 88 | script.main) | ||
305 | 89 | |||
306 | 90 | def test_single_by_name(self): | ||
307 | 91 | person_id, account_id = self.getSampleUser('mark', 'mark@example.com') | ||
308 | 92 | script = self.makeScript(['mark']) | ||
309 | 93 | with dbuser('launchpad'): | ||
310 | 94 | script.main() | ||
311 | 95 | self.assertRemoved(account_id, person_id) | ||
312 | 96 | |||
313 | 97 | def test_single_by_email(self): | ||
314 | 98 | person_id, account_id = self.getSampleUser('mark', 'mark@example.com') | ||
315 | 99 | script = self.makeScript(['mark@example.com']) | ||
316 | 100 | with dbuser('launchpad'): | ||
317 | 101 | script.main() | ||
318 | 102 | self.assertRemoved(account_id, person_id) | ||
319 | 103 | |||
320 | 104 | def test_multiple(self): | ||
321 | 105 | persons = [self.factory.makePerson() for _ in range(3)] | ||
322 | 106 | person_ids = [person.id for person in persons] | ||
323 | 107 | account_ids = [person.account.id for person in persons] | ||
324 | 108 | script = self.makeScript([persons[0].name, persons[1].name]) | ||
325 | 109 | with dbuser('launchpad'): | ||
326 | 110 | script.main() | ||
327 | 111 | self.assertRemoved(account_ids[0], person_ids[0]) | ||
328 | 112 | self.assertRemoved(account_ids[1], person_ids[1]) | ||
329 | 113 | self.assertNotRemoved(account_ids[2], person_ids[2]) | ||
330 | 0 | 114 | ||
331 | === removed file 'lib/lp/services/identity/doc/close-account.txt' | |||
332 | --- lib/lp/services/identity/doc/close-account.txt 2011-12-29 05:29:36 +0000 | |||
333 | +++ lib/lp/services/identity/doc/close-account.txt 1970-01-01 00:00:00 +0000 | |||
334 | @@ -1,83 +0,0 @@ | |||
335 | 1 | We have a command line script that can be used to close accounts. | ||
336 | 2 | |||
337 | 3 | Unfortunately, we have no way of detecting schema updates containing new | ||
338 | 4 | information that needs to be removed or sanitized on account closure apart | ||
339 | 5 | from reviewers noticing and prompting developers to update this script. | ||
340 | 6 | |||
341 | 7 | See Bug #120506 for more details. | ||
342 | 8 | |||
343 | 9 | |||
344 | 10 | Get Mark's account and person entries. | ||
345 | 11 | >>> from lp.registry.interfaces.person import IPersonSet | ||
346 | 12 | >>> from zope.security.proxy import removeSecurityProxy | ||
347 | 13 | >>> mark_person = getUtility(IPersonSet).getByEmail('mark@example.com') | ||
348 | 14 | >>> mark_account = removeSecurityProxy(mark_person.account) | ||
349 | 15 | |||
350 | 16 | |||
351 | 17 | Mark's account is active and contains personal information. | ||
352 | 18 | |||
353 | 19 | >>> print mark_account.status.name | ||
354 | 20 | ACTIVE | ||
355 | 21 | >>> print mark_account.displayname | ||
356 | 22 | Mark Shuttleworth | ||
357 | 23 | >>> print mark_person.name | ||
358 | 24 | mark | ||
359 | 25 | >>> print mark_person.displayname | ||
360 | 26 | Mark Shuttleworth | ||
361 | 27 | |||
362 | 28 | |||
363 | 29 | Store the id's so we can retrieve the records later. | ||
364 | 30 | |||
365 | 31 | >>> mark_person_id = mark_person.id | ||
366 | 32 | >>> mark_account_id = mark_account.id | ||
367 | 33 | |||
368 | 34 | |||
369 | 35 | Lets close his account. | ||
370 | 36 | |||
371 | 37 | >>> import os.path | ||
372 | 38 | >>> from lp.services.config import config | ||
373 | 39 | >>> from lp.testing.script import run_script | ||
374 | 40 | >>> script = os.path.join(config.root, 'scripts', 'close-account.py') | ||
375 | 41 | >>> (result, out, err) = run_script(script, args=['mark@example.com']) | ||
376 | 42 | >>> print result | ||
377 | 43 | 0 | ||
378 | 44 | >>> print out | ||
379 | 45 | >>> print err | ||
380 | 46 | INFO...Closing mark's account | ||
381 | 47 | |||
382 | 48 | |||
383 | 49 | Now, start a new transaction so we can see the changes the script made. | ||
384 | 50 | |||
385 | 51 | >>> from lp.testing.layers import LaunchpadZopelessLayer | ||
386 | 52 | >>> LaunchpadZopelessLayer.abort() | ||
387 | 53 | |||
388 | 54 | |||
389 | 55 | We can't just set the account to DEACTIVATED, as the close-account.py | ||
390 | 56 | script is used to satisty people who insist on us removing all their | ||
391 | 57 | personal details from our system. The Account has been removed entirely. | ||
392 | 58 | |||
393 | 59 | >>> from lp.services.identity.model.account import Account | ||
394 | 60 | >>> Account.get(mark_account_id) | ||
395 | 61 | Traceback (most recent call last): | ||
396 | 62 | ... | ||
397 | 63 | SQLObjectNotFound: ... | ||
398 | 64 | |||
399 | 65 | |||
400 | 66 | The Person record still exists to maintain links with information that won't | ||
401 | 67 | be removed, such as bug comments, but has been anonymized. | ||
402 | 68 | |||
403 | 69 | >>> from lp.registry.model.person import Person | ||
404 | 70 | >>> mark_person = Person.get(mark_person_id) | ||
405 | 71 | >>> print mark_person.name | ||
406 | 72 | removed... | ||
407 | 73 | >>> print mark_person.displayname | ||
408 | 74 | Removed by request | ||
409 | 75 | |||
410 | 76 | |||
411 | 77 | Flag the database as dirty since it has been modified without the test suite | ||
412 | 78 | knowing. | ||
413 | 79 | |||
414 | 80 | >>> from lp.testing.layers import DatabaseLayer | ||
415 | 81 | >>> DatabaseLayer.force_dirty_database() | ||
416 | 82 | |||
417 | 83 | |||
418 | 84 | 0 | ||
419 | === modified file 'lib/lp/services/identity/tests/test_doc.py' | |||
420 | --- lib/lp/services/identity/tests/test_doc.py 2012-01-01 02:58:52 +0000 | |||
421 | +++ lib/lp/services/identity/tests/test_doc.py 2018-11-28 18:58:57 +0000 | |||
422 | @@ -1,4 +1,4 @@ | |||
424 | 1 | # Copyright 2009-2011 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
425 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
426 | 3 | 3 | ||
427 | 4 | """ | 4 | """ |
428 | @@ -8,27 +8,12 @@ | |||
429 | 8 | import os | 8 | import os |
430 | 9 | 9 | ||
431 | 10 | from lp.services.testing import build_test_suite | 10 | from lp.services.testing import build_test_suite |
441 | 11 | from lp.testing.layers import ( | 11 | from lp.testing.layers import DatabaseFunctionalLayer |
433 | 12 | DatabaseFunctionalLayer, | ||
434 | 13 | LaunchpadZopelessLayer, | ||
435 | 14 | ) | ||
436 | 15 | from lp.testing.systemdocs import ( | ||
437 | 16 | LayeredDocFileSuite, | ||
438 | 17 | setUp, | ||
439 | 18 | tearDown, | ||
440 | 19 | ) | ||
442 | 20 | 12 | ||
443 | 21 | 13 | ||
444 | 22 | here = os.path.dirname(os.path.realpath(__file__)) | 14 | here = os.path.dirname(os.path.realpath(__file__)) |
445 | 23 | 15 | ||
446 | 24 | 16 | ||
447 | 25 | special = { | ||
448 | 26 | 'close-account.txt': LayeredDocFileSuite( | ||
449 | 27 | '../doc/close-account.txt', setUp=setUp, tearDown=tearDown, | ||
450 | 28 | layer=LaunchpadZopelessLayer), | ||
451 | 29 | } | ||
452 | 30 | |||
453 | 31 | |||
454 | 32 | def test_suite(): | 17 | def test_suite(): |
456 | 33 | suite = build_test_suite(here, special, layer=DatabaseFunctionalLayer) | 18 | suite = build_test_suite(here, layer=DatabaseFunctionalLayer) |
457 | 34 | return suite | 19 | return suite |
458 | 35 | 20 | ||
459 | === modified file 'scripts/close-account.py' | |||
460 | --- scripts/close-account.py 2018-09-12 11:53:08 +0000 | |||
461 | +++ scripts/close-account.py 2018-11-28 18:58:57 +0000 | |||
462 | @@ -5,219 +5,11 @@ | |||
463 | 5 | 5 | ||
464 | 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.""" |
465 | 7 | 7 | ||
466 | 8 | __metaclass__ = type | ||
467 | 9 | __all__ = [] | ||
468 | 10 | |||
469 | 11 | import _pythonpath | 8 | import _pythonpath |
470 | 12 | 9 | ||
678 | 13 | from optparse import OptionParser | 10 | from lp.registry.scripts.closeaccount import CloseAccountScript |
472 | 14 | import sys | ||
473 | 15 | |||
474 | 16 | from lp.answers.enums import QuestionStatus | ||
475 | 17 | from lp.registry.interfaces.person import PersonCreationRationale | ||
476 | 18 | from lp.services.database.sqlbase import ( | ||
477 | 19 | connect, | ||
478 | 20 | sqlvalues, | ||
479 | 21 | ) | ||
480 | 22 | from lp.services.scripts import ( | ||
481 | 23 | db_options, | ||
482 | 24 | logger, | ||
483 | 25 | logger_options, | ||
484 | 26 | ) | ||
485 | 27 | |||
486 | 28 | |||
487 | 29 | def close_account(con, log, username): | ||
488 | 30 | """Close a person's account. | ||
489 | 31 | |||
490 | 32 | Return True on success, or log an error message and return False | ||
491 | 33 | """ | ||
492 | 34 | cur = con.cursor() | ||
493 | 35 | cur.execute(""" | ||
494 | 36 | SELECT Person.id, Person.account, name, teamowner | ||
495 | 37 | FROM Person | ||
496 | 38 | LEFT OUTER JOIN EmailAddress ON Person.id = EmailAddress.person | ||
497 | 39 | WHERE name = %(username)s OR lower(email) = lower(%(username)s) | ||
498 | 40 | """, vars()) | ||
499 | 41 | try: | ||
500 | 42 | person_id, account_id, username, teamowner = cur.fetchone() | ||
501 | 43 | except TypeError: | ||
502 | 44 | log.fatal("User %s does not exist" % username) | ||
503 | 45 | return False | ||
504 | 46 | |||
505 | 47 | # We don't do teams | ||
506 | 48 | if teamowner is not None: | ||
507 | 49 | log.fatal("%s is a team" % username) | ||
508 | 50 | return False | ||
509 | 51 | |||
510 | 52 | log.info("Closing %s's account" % username) | ||
511 | 53 | |||
512 | 54 | def table_notification(table): | ||
513 | 55 | log.debug("Handling the %s table" % table) | ||
514 | 56 | |||
515 | 57 | # All names starting with 'removed' are blacklisted, so this will always | ||
516 | 58 | # succeed. | ||
517 | 59 | new_name = 'removed%d' % person_id | ||
518 | 60 | |||
519 | 61 | # Remove the EmailAddress. This is the most important step, as | ||
520 | 62 | # people requesting account removal seem to primarily be interested | ||
521 | 63 | # in ensuring we no longer store this information. | ||
522 | 64 | table_notification('EmailAddress') | ||
523 | 65 | cur.execute(""" | ||
524 | 66 | DELETE FROM EmailAddress WHERE person = %s | ||
525 | 67 | """ % sqlvalues(person_id)) | ||
526 | 68 | |||
527 | 69 | # Clean out personal details from the Person table | ||
528 | 70 | table_notification('Person') | ||
529 | 71 | unknown_rationale = PersonCreationRationale.UNKNOWN.value | ||
530 | 72 | cur.execute(""" | ||
531 | 73 | UPDATE Person | ||
532 | 74 | SET | ||
533 | 75 | displayname = 'Removed by request', | ||
534 | 76 | name=%(new_name)s, | ||
535 | 77 | language = NULL, | ||
536 | 78 | account = NULL, | ||
537 | 79 | homepage_content = NULL, | ||
538 | 80 | icon = NULL, | ||
539 | 81 | mugshot = NULL, | ||
540 | 82 | hide_email_addresses = TRUE, | ||
541 | 83 | registrant = NULL, | ||
542 | 84 | logo = NULL, | ||
543 | 85 | creation_rationale = %(unknown_rationale)s, | ||
544 | 86 | creation_comment = NULL | ||
545 | 87 | WHERE id = %(person_id)s | ||
546 | 88 | """, vars()) | ||
547 | 89 | |||
548 | 90 | # Remove the Account. We don't set the status to deactivated, | ||
549 | 91 | # as this script is used to satisfy people who insist on us removing | ||
550 | 92 | # all their personal details from our systems. This includes any | ||
551 | 93 | # identification tokens like email addresses or openid identifiers. | ||
552 | 94 | # So the Account record would be unusable, and contain no useful | ||
553 | 95 | # information. | ||
554 | 96 | table_notification('Account') | ||
555 | 97 | if account_id is not None: | ||
556 | 98 | cur.execute(""" | ||
557 | 99 | DELETE FROM Account WHERE id = %s | ||
558 | 100 | """ % sqlvalues(account_id)) | ||
559 | 101 | |||
560 | 102 | # Reassign their bugs | ||
561 | 103 | table_notification('BugTask') | ||
562 | 104 | cur.execute(""" | ||
563 | 105 | UPDATE BugTask SET assignee = NULL WHERE assignee = %(person_id)s | ||
564 | 106 | """, vars()) | ||
565 | 107 | |||
566 | 108 | # Reassign questions assigned to the user, and close all their questions | ||
567 | 109 | # since nobody else can | ||
568 | 110 | table_notification('Question') | ||
569 | 111 | cur.execute(""" | ||
570 | 112 | UPDATE Question SET assignee=NULL WHERE assignee=%(person_id)s | ||
571 | 113 | """, vars()) | ||
572 | 114 | closed_question_status = QuestionStatus.SOLVED.value | ||
573 | 115 | cur.execute(""" | ||
574 | 116 | UPDATE Question | ||
575 | 117 | SET status=%(closed_question_status)s, whiteboard= | ||
576 | 118 | 'Closed by Launchpad due to owner requesting account removal' | ||
577 | 119 | WHERE owner=%(person_id)s | ||
578 | 120 | """, vars()) | ||
579 | 121 | |||
580 | 122 | # Remove rows from tables in simple cases in the given order | ||
581 | 123 | removals = [ | ||
582 | 124 | # Trash their email addresses. People who request complete account | ||
583 | 125 | # removal would be unhappy if they reregistered with their old email | ||
584 | 126 | # address and this resurrected their deleted account, as the email | ||
585 | 127 | # address is probably the piece of data we store that they were most | ||
586 | 128 | # concerned with being removed from our systems. | ||
587 | 129 | ('EmailAddress', 'person'), | ||
588 | 130 | |||
589 | 131 | # Trash their codes of conduct and GPG keys | ||
590 | 132 | ('SignedCodeOfConduct', 'owner'), | ||
591 | 133 | ('GpgKey', 'owner'), | ||
592 | 134 | |||
593 | 135 | # Subscriptions | ||
594 | 136 | ('BranchSubscription', 'person'), | ||
595 | 137 | ('GitSubscription', 'person'), | ||
596 | 138 | ('BugSubscription', 'person'), | ||
597 | 139 | ('QuestionSubscription', 'person'), | ||
598 | 140 | ('SpecificationSubscription', 'person'), | ||
599 | 141 | |||
600 | 142 | # Personal stuff, freeing up the namespace for others who want to play | ||
601 | 143 | # or just to remove any fingerprints identifying the user. | ||
602 | 144 | ('IrcId', 'person'), | ||
603 | 145 | ('JabberId', 'person'), | ||
604 | 146 | ('WikiName', 'person'), | ||
605 | 147 | ('PersonLanguage', 'person'), | ||
606 | 148 | ('PersonLocation', 'person'), | ||
607 | 149 | ('SshKey', 'person'), | ||
608 | 150 | |||
609 | 151 | # Karma | ||
610 | 152 | ('Karma', 'person'), | ||
611 | 153 | ('KarmaCache', 'person'), | ||
612 | 154 | ('KarmaTotalCache', 'person'), | ||
613 | 155 | |||
614 | 156 | # Team memberships | ||
615 | 157 | ('TeamMembership', 'person'), | ||
616 | 158 | ('TeamParticipation', 'person'), | ||
617 | 159 | |||
618 | 160 | # Contacts | ||
619 | 161 | ('AnswerContact', 'person'), | ||
620 | 162 | |||
621 | 163 | # Pending items in queues | ||
622 | 164 | ('POExportRequest', 'person'), | ||
623 | 165 | |||
624 | 166 | # Access grants | ||
625 | 167 | ('GitRuleGrant', 'grantee'), | ||
626 | 168 | ] | ||
627 | 169 | for table, person_id_column in removals: | ||
628 | 170 | table_notification(table) | ||
629 | 171 | cur.execute(""" | ||
630 | 172 | DELETE FROM %(table)s WHERE %(person_id_column)s=%(person_id)d | ||
631 | 173 | """ % vars()) | ||
632 | 174 | |||
633 | 175 | # Trash Sprint Attendance records in the future. | ||
634 | 176 | table_notification('SprintAttendance') | ||
635 | 177 | cur.execute(""" | ||
636 | 178 | DELETE FROM SprintAttendance | ||
637 | 179 | USING Sprint | ||
638 | 180 | WHERE Sprint.id = SprintAttendance.sprint | ||
639 | 181 | AND attendee=%(person_id)s | ||
640 | 182 | AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC' | ||
641 | 183 | """, vars()) | ||
642 | 184 | |||
643 | 185 | return True | ||
644 | 186 | |||
645 | 187 | |||
646 | 188 | def main(): | ||
647 | 189 | parser = OptionParser( | ||
648 | 190 | '%prog [options] (username|email) [...]' | ||
649 | 191 | ) | ||
650 | 192 | db_options(parser) | ||
651 | 193 | logger_options(parser) | ||
652 | 194 | |||
653 | 195 | (options, args) = parser.parse_args() | ||
654 | 196 | |||
655 | 197 | if len(args) == 0: | ||
656 | 198 | parser.error("Must specify username (Person.name)") | ||
657 | 199 | |||
658 | 200 | log = logger(options) | ||
659 | 201 | |||
660 | 202 | con = None | ||
661 | 203 | try: | ||
662 | 204 | log.debug("Connecting to database") | ||
663 | 205 | con = connect() | ||
664 | 206 | for username in args: | ||
665 | 207 | if not close_account(con, log, username): | ||
666 | 208 | log.debug("Rolling back") | ||
667 | 209 | con.rollback() | ||
668 | 210 | return 1 | ||
669 | 211 | log.debug("Committing changes") | ||
670 | 212 | con.commit() | ||
671 | 213 | return 0 | ||
672 | 214 | except: | ||
673 | 215 | log.exception("Unhandled exception") | ||
674 | 216 | log.debug("Rolling back") | ||
675 | 217 | if con is not None: | ||
676 | 218 | con.rollback() | ||
677 | 219 | return 1 | ||
679 | 220 | 11 | ||
680 | 221 | 12 | ||
681 | 222 | if __name__ == '__main__': | 13 | if __name__ == '__main__': |
683 | 223 | sys.exit(main()) | 14 | script = CloseAccountScript('close-account', dbuser='launchpad') |
684 | 15 | script.run() |
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.