← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ilasc/launchpad:close-account-celery-job into launchpad:master

 

Ioana Lasc has proposed merging ~ilasc/launchpad:close-account-celery-job into launchpad:master.

Commit message:
Add a close account celery job

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/400261
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilasc/launchpad:close-account-celery-job into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 2911da2..6a8e811 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -153,6 +153,7 @@ public.bugtrackercomponentgroup         = SELECT, INSERT, UPDATE
 public.bugtrackerperson                 = SELECT, UPDATE
 public.bugwatchactivity                 = SELECT, INSERT, UPDATE
 public.buildfarmjob                     = DELETE
+public.closeaccountjobobject            = SELECT, INSERT, UPDATE
 public.codeimport                       = SELECT, INSERT, UPDATE, DELETE
 public.codeimportevent                  = SELECT, INSERT, UPDATE
 public.codeimporteventdata              = SELECT, INSERT
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 7a7a396..c959246 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -418,6 +418,19 @@
 
         <!-- string:${id} because id is an int -->
 
+    <!-- Close Account Celery Job -->
+    <class
+        class="lp.registry.model.closeaccount.CloseAccountJobObject">
+        <allow
+            interface="lp.registry.model.closeaccount.ICloseAccountJob"/>
+    </class>
+
+    <securedutility
+        component="lp.registry.model.closeaccount.CloseAccountJob"
+        provides="lp.registry.model.closeaccount.ICloseAccountJobSource">
+        <allow interface="lp.registry.model.closeaccount.ICloseAccountJobSource" />
+    </securedutility>
+
     <class
         class="lp.registry.model.person.IrcID">
         <allow
diff --git a/lib/lp/registry/model/closeaccount.py b/lib/lp/registry/model/closeaccount.py
new file mode 100644
index 0000000..087d63c
--- /dev/null
+++ b/lib/lp/registry/model/closeaccount.py
@@ -0,0 +1,601 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+import pytz
+
+from lazr.delegates import delegate_to
+
+from storm.exceptions import IntegrityError
+from zope.schema._bootstrapfields import Object
+
+from lp.services.database.enumcol import EnumCol
+from lazr.enum import (
+    DBEnumeratedType,
+    DBItem,
+    )
+from lp.app.errors import NotFoundError
+from lp.services.database.interfaces import IStore
+from lp.services.database.locking import AdvisoryLockHeld
+from lp.services.database.stormbase import StormBase
+from lp.services.job.interfaces.job import IRunnableJob, IJobSource, IJob
+from lp.services.job.runner import BaseRunnableJob
+from zope.interface import (
+    implementer,
+    provider, Interface,
+)
+from storm.properties import (
+    DateTime,
+    Unicode,
+    )
+from lp.services.config import config
+from storm.locals import (
+    Int,
+    Reference,
+    )
+from lp.services.job.model.job import (
+    Job,
+    )
+from lp.services.scripts import log
+
+from storm.expr import (
+    LeftJoin,
+    Lower,
+    Or,
+    )
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.answers.enums import QuestionStatus
+from lp.answers.model.question import Question
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.bugs.model.bugtask import BugTask
+from lp.registry.interfaces.person import PersonCreationRationale
+from lp.registry.model.person import (
+    Person,
+    PersonSettings,
+    )
+from lp.registry.model.product import Product
+from lp.registry.model.productseries import ProductSeries
+from lp.services.database import postgresql
+from lp.services.database.constants import DEFAULT
+from lp.services.database.interfaces import IMasterStore
+from lp.services.database.sqlbase import cursor
+from lp.services.identity.interfaces.account import (
+    AccountCreationRationale,
+    AccountStatus,
+    )
+from lp.services.identity.model.emailaddress import EmailAddress
+from lp.services.openid.model.openididentifier import OpenIdIdentifier
+from lp.services.scripts.base import (
+    LaunchpadScriptFailure,
+    )
+from lp.soyuz.enums import (
+    ArchiveStatus,
+    ArchiveSubscriberStatus,
+    )
+from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet
+from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.archivesubscriber import ArchiveSubscriber
+from lp import _
+
+
+class ICloseAccountRunnable(IRunnableJob):
+    """A Job that closes an account."""
+
+
+class ICloseAccountJob(Interface):
+
+    job = Object(title=_('The common Job attributes'), schema=IJob,
+        required=True)
+
+
+class ICloseAccountJobSource(IJobSource):
+
+    def create(username):
+        """Close an account for the given username.
+
+        :param username: The username to close the account for.
+        """
+
+
+class CloseAccountJobType(DBEnumeratedType):
+    """Values that IQuestionJob.job_type can take."""
+
+    CLOSE_ACCOUNT = DBItem(0, """
+        Close account
+
+        Close a Launchpad account for a given username.
+        """)
+
+
+@implementer(ICloseAccountJob)
+class CloseAccountJobObject(StormBase):
+
+    __storm_table__ = 'CloseAccountJobObject'
+    id = Int(primary=True)
+    status = Int(name='status', allow_none=True)
+
+    job_id = Int(name='job', allow_none=False)
+    job = Reference(job_id, 'Job.id')
+    job_type = EnumCol(enum=CloseAccountJobType, notNull=True)
+
+    username = Unicode(allow_none=False)
+    exception = Unicode(allow_none=True)
+
+    date_created = DateTime(tzinfo=pytz.UTC, allow_none=True)
+    date_finished = DateTime(tzinfo=pytz.UTC, allow_none=True)
+
+    def __init__(self, username, job_type):
+        """Constructor.
+
+        Extra keyword arguments are used to construct the underlying Job
+        object.
+
+        :param username: The database username this job relates to.
+        :param metadata: The type-specific variables, as a JSON-compatible
+            dict.
+        """
+        super(CloseAccountJobObject, self).__init__()
+        self.username = username
+        self.job = Job()
+        self.job_type = job_type
+
+
+@delegate_to(ICloseAccountJob)
+@implementer(ICloseAccountRunnable)
+@provider(ICloseAccountJobSource)
+class CloseAccountJob(BaseRunnableJob):
+    """A Job that closes and account for the provided username."""
+    class_job_type = CloseAccountJobType.CLOSE_ACCOUNT
+
+    max_retries = 5
+
+    retry_error_types = (AdvisoryLockHeld,)
+
+    config = config.ICloseAccountJobSource
+
+    def __init__(self, job):
+        self.context = job
+
+    @classmethod
+    def get(cls, job_id):
+        close_account_job = IStore(CloseAccountJobObject).get(CloseAccountJobObject, job_id)
+        if close_account_job.job_type != cls.class_job_type:
+            raise NotFoundError(
+                "No object found with id %d and type %s" %
+                (job_id, cls.class_job_type.title))
+        return cls(close_account_job)
+
+    @classmethod
+    def iterReady(cls):
+        jobs = IMasterStore(CloseAccountJobObject).find(
+            CloseAccountJobObject,
+            CloseAccountJobObject.job_type == cls.class_job_type,
+            CloseAccountJobObject.job == Job.id,
+            Job.id.is_in(Job.ready_jobs))
+        return (cls(job) for job in jobs)
+
+    @classmethod
+    def create(cls, username):
+        close_account_job = CloseAccountJobObject(username, CloseAccountJobType.CLOSE_ACCOUNT)
+        job = cls(close_account_job)
+        job.celeryRunOnCommit()
+        return job
+
+    def run(self):
+        # with try_advisory_lock(
+        #         LockType.CLOSE_ACCOUNT, self.username):
+        result = self.close_account(self.context.username)
+        if result is not True:
+            self.context.exception = result
+
+    def close_account(self, username):
+        """Close a person's account.
+
+        Return True on success, or log an error message and return False
+        """
+        store = IMasterStore(Person)
+        janitor = getUtility(ILaunchpadCelebrities).janitor
+
+        cur = cursor()
+        references = list(postgresql.listReferences(cur, 'person', 'id'))
+        postgresql.check_indirect_references(references)
+
+        person = store.using(
+            Person,
+            LeftJoin(EmailAddress, Person.id == EmailAddress.personID)
+        ).find(
+            Person,
+            Or(Person.name == username,
+               Lower(EmailAddress.email) == Lower(username))
+        ).order_by(Person.id).config(distinct=True).one()
+        if person is None:
+            return "User %s does not exist" % username
+        person_name = person.name
+
+        # We don't do teams
+        if person.is_team:
+            return "%s is a team" % person_name
+
+        log.info("Closing %s's account" % person_name)
+
+        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
+
+        # Some references can safely remain in place and link to the cleaned-out
+        # Person row.
+        skip = {
+            # These references express some kind of audit trail.  The actions in
+            # question still happened, and in some cases the rows may still have
+            # functional significance (e.g. subscriptions or access grants), but
+            # we no longer identify the actor.
+            ('accessartifactgrant', 'grantor'),
+            ('accesspolicygrant', 'grantor'),
+            ('binarypackagepublishinghistory', 'removed_by'),
+            ('branch', 'registrant'),
+            ('branchmergeproposal', 'merge_reporter'),
+            ('branchmergeproposal', 'merger'),
+            ('branchmergeproposal', 'queuer'),
+            ('branchmergeproposal', 'registrant'),
+            ('branchmergeproposal', 'reviewer'),
+            ('branchsubscription', 'subscribed_by'),
+            ('bug', 'owner'),
+            ('bug', 'who_made_private'),
+            ('bugactivity', 'person'),
+            ('bugnomination', 'decider'),
+            ('bugnomination', 'owner'),
+            ('bugtask', 'owner'),
+            ('bugsubscription', 'subscribed_by'),
+            ('codeimport', 'owner'),
+            ('codeimport', 'registrant'),
+            ('codeimportjob', 'requesting_user'),
+            ('codeimportevent', 'person'),
+            ('codeimportresult', 'requesting_user'),
+            ('distroarchseriesfilter', 'creator'),
+            ('faq', 'last_updated_by'),
+            ('featureflagchangelogentry', 'person'),
+            ('gitactivity', 'changee'),
+            ('gitactivity', 'changer'),
+            ('gitrepository', 'registrant'),
+            ('gitrule', 'creator'),
+            ('gitrulegrant', 'grantor'),
+            ('gitsubscription', 'subscribed_by'),
+            ('job', 'requester'),
+            ('message', 'owner'),
+            ('messageapproval', 'disposed_by'),
+            ('messageapproval', 'posted_by'),
+            ('packagecopyrequest', 'requester'),
+            ('packagediff', 'requester'),
+            ('packageupload', 'signing_key_owner'),
+            ('personlocation', 'last_modified_by'),
+            ('persontransferjob', 'major_person'),
+            ('persontransferjob', 'minor_person'),
+            ('poexportrequest', 'person'),
+            ('pofile', 'lasttranslator'),
+            ('pofiletranslator', 'person'),
+            ('product', 'registrant'),
+            ('question', 'answerer'),
+            ('questionreopening', 'answerer'),
+            ('questionreopening', 'reopener'),
+            ('snapbuild', 'requester'),
+            ('sourcepackagepublishinghistory', 'creator'),
+            ('sourcepackagepublishinghistory', 'removed_by'),
+            ('sourcepackagepublishinghistory', 'sponsor'),
+            ('sourcepackagerecipebuild', 'requester'),
+            ('sourcepackagerelease', 'creator'),
+            ('sourcepackagerelease', 'maintainer'),
+            ('sourcepackagerelease', 'signing_key_owner'),
+            ('specification', 'approver'),
+            ('specification', 'completer'),
+            ('specification', 'drafter'),
+            ('specification', 'goal_decider'),
+            ('specification', 'goal_proposer'),
+            ('specification', 'last_changed_by'),
+            ('specification', 'owner'),
+            ('specification', 'starter'),
+            ('structuralsubscription', 'subscribed_by'),
+            ('teammembership', 'acknowledged_by'),
+            ('teammembership', 'last_changed_by'),
+            ('teammembership', 'proposed_by'),
+            ('teammembership', 'reviewed_by'),
+            ('translationimportqueueentry', 'importer'),
+            ('translationmessage', 'reviewer'),
+            ('translationmessage', 'submitter'),
+            ('translationrelicensingagreement', 'person'),
+            ('usertouseremail', 'recipient'),
+            ('usertouseremail', 'sender'),
+            ('xref', 'creator'),
+
+            # This is maintained by trigger functions and a garbo job.  It
+            # doesn't need to be updated immediately.
+            ('bugsummary', 'viewed_by'),
+
+            # XXX cjwatson 2019-05-02 bug=1827399: This is suboptimal because it
+            # does retain some personal information, but it's currently hard to
+            # deal with due to the size and complexity of references to it.  We
+            # can hopefully provide a garbo job for this eventually.
+            ('revisionauthor', 'person'),
+            }
+
+        # If all the teams that the user owns
+        # have been deleted (not just one) skip Person.teamowner
+        teams = store.find(Person, Person.teamowner == person)
+        if all(team.merged is not None for team in teams):
+            skip.add(('person', 'teamowner'))
+
+        reference_names = {
+            (src_tab, src_col) for src_tab, src_col, _, _, _, _ in references}
+        for src_tab, src_col in skip:
+            if (src_tab, src_col) not in reference_names:
+                raise AssertionError(
+                    "%s.%s is not a Person reference; possible typo?" %
+                    (src_tab, src_col))
+
+        # XXX cjwatson 2018-11-29: Registrants could possibly be left as-is, but
+        # perhaps we should pretend that the registrant was ~registry in that
+        # case instead?
+
+        # 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')
+        person.display_name = 'Removed by request'
+        person.name = new_name
+        person.homepage_content = None
+        person.icon = None
+        person.mugshot = None
+        person.hide_email_addresses = False
+        person.registrant = None
+        person.logo = None
+        person.creation_rationale = PersonCreationRationale.UNKNOWN
+        person.creation_comment = None
+
+        # Keep the corresponding PersonSettings row, but reset everything to the
+        # defaults.
+        table_notification('PersonSettings')
+        store.find(PersonSettings, PersonSettings.personID == person.id).set(
+            selfgenerated_bugnotifications=DEFAULT,
+            # XXX cjwatson 2018-11-29: These two columns have NULL defaults, but
+            # perhaps shouldn't?
+            expanded_notification_footers=False,
+            require_strong_email_authentication=False)
+        skip.add(('personsettings', 'person'))
+
+        # Remove almost everything from the Account row and the corresponding
+        # OpenIdIdentifier rows, preserving only a minimal audit trail.
+        if person.account is not None:
+            table_notification('Account')
+            account = removeSecurityProxy(person.account)
+            account.displayname = 'Removed by request'
+            account.creation_rationale = AccountCreationRationale.UNKNOWN
+            person.setAccountStatus(
+                AccountStatus.CLOSED, janitor, "Closed using close-account.")
+
+            table_notification('OpenIdIdentifier')
+            store.find(
+                OpenIdIdentifier,
+                OpenIdIdentifier.account_id == account.id).remove()
+
+        # Reassign their bugs
+        table_notification('BugTask')
+        store.find(BugTask, BugTask.assignee_id == person.id).set(assignee_id=None)
+
+        # Reassign questions assigned to the user, and close all their questions
+        # in non-final states since nobody else can.
+        table_notification('Question')
+        store.find(Question, Question.assignee_id == person.id).set(
+            assignee_id=None)
+        owned_non_final_questions = store.find(
+            Question, Question.owner_id == person.id,
+            Question.status.is_in([
+                QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
+                QuestionStatus.ANSWERED,
+                ]))
+        owned_non_final_questions.set(
+            status=QuestionStatus.SOLVED,
+            whiteboard=(
+                u'Closed by Launchpad due to owner requesting account removal'))
+        skip.add(('question', 'owner'))
+
+        # 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'),
+
+            # Login and OAuth tokens are no longer interesting if the user can
+            # no longer log in.
+            ('LoginToken', 'requester'),
+            ('OAuthAccessToken', 'person'),
+            ('OAuthRequestToken', 'person'),
+
+            # Trash their codes of conduct and GPG keys
+            ('SignedCodeOfConduct', 'owner'),
+            ('GpgKey', 'owner'),
+
+            # Subscriptions and notifications
+            ('BranchSubscription', 'person'),
+            ('BugMute', 'person'),
+            ('BugNotificationRecipient', 'person'),
+            ('BugSubscription', 'person'),
+            ('BugSubscriptionFilterMute', 'person'),
+            ('GitSubscription', 'person'),
+            ('MailingListSubscription', 'person'),
+            ('QuestionSubscription', 'person'),
+            ('SpecificationSubscription', 'person'),
+            ('StructuralSubscription', 'subscriber'),
+
+            # 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
+            ('AccessArtifactGrant', 'grantee'),
+            ('AccessPolicyGrant', 'grantee'),
+            ('ArchivePermission', 'person'),
+            ('GitRuleGrant', 'grantee'),
+            ('SharingJob', 'grantee'),
+
+            # Soyuz reporting
+            ('LatestPersonSourcePackageReleaseCache', 'creator'),
+            ('LatestPersonSourcePackageReleaseCache', 'maintainer'),
+
+            # "Affects me too" information
+            ('BugAffectsPerson', 'person'),
+            ]
+        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,))
+        # Any remaining past sprint attendance records can harmlessly refer to
+        # the placeholder person row.
+        skip.add(('sprintattendance', 'attendee'))
+
+        # generate_ppa_htaccess currently relies on seeing active
+        # ArchiveAuthToken rows so that it knows which ones to remove from
+        # .htpasswd files on disk in response to the cancellation of the
+        # corresponding ArchiveSubscriber rows; but even once PPA authorisation
+        # is handled dynamically, we probably still want to have the per-person
+        # audit trail here.
+        archive_subscriber_ids = set(store.find(
+            ArchiveSubscriber.id,
+            ArchiveSubscriber.subscriber_id == person.id,
+            ArchiveSubscriber.status == ArchiveSubscriberStatus.CURRENT))
+        if archive_subscriber_ids:
+            getUtility(IArchiveSubscriberSet).cancel(
+                archive_subscriber_ids, janitor)
+        skip.add(('archivesubscriber', 'subscriber'))
+        skip.add(('archiveauthtoken', 'person'))
+
+        # Remove hardware submissions.
+        table_notification('HWSubmissionDevice')
+        store.execute("""
+            DELETE FROM HWSubmissionDevice
+            USING HWSubmission
+            WHERE HWSubmission.id = HWSubmissionDevice.submission
+                AND owner = ?
+            """, (person.id,))
+        table_notification('HWSubmission')
+        store.execute("""
+            DELETE FROM HWSubmission
+            WHERE HWSubmission.owner = ?
+            """, (person.id,))
+
+        # Purge deleted PPAs.  This is safe because the archive can only be in
+        # the DELETED status if the publisher has removed it from disk and set
+        # all its publications to DELETED.
+        # XXX cjwatson 2019-08-09: This will fail if anything non-trivial has
+        # been done in this person's PPAs; and it's not obvious what to do in
+        # more complicated cases such as builds having been copied out
+        # elsewhere.  It's good enough for some simple cases, though.
+        try:
+            store.find(
+                Archive,
+                Archive.owner == person,
+                Archive.status == ArchiveStatus.DELETED).remove()
+        except IntegrityError:
+            raise LaunchpadScriptFailure(
+                "Can't delete non-trivial PPAs for user %s" % person_name)
+
+        has_references = False
+
+        # Check for active related projects, and skip inactive ones.
+        for col in 'bug_supervisor', 'driver', 'owner':
+            # Raw SQL because otherwise using Product._owner while displaying it
+            # as Product.owner is too fiddly.
+            result = store.execute("""
+                SELECT COUNT(*) FROM product WHERE active AND %(col)s = ?
+                """ % {'col': col},
+                (person.id,))
+            count = result.get_one()[0]
+            if count:
+                log.error(
+                    "User %s is still referenced by %d product.%s values" %
+                    (person_name, count, col))
+                has_references = True
+            skip.add(('product', col))
+        for col in 'driver', 'owner':
+            count = store.find(
+                ProductSeries,
+                ProductSeries.product == Product.id, Product.active,
+                getattr(ProductSeries, col) == person).count()
+            if count:
+                log.error(
+                    "User %s is still referenced by %d productseries.%s values" %
+                    (person_name, count, col))
+                has_references = True
+            skip.add(('productseries', col))
+
+        # Closing the account will only work if all references have been handled
+        # by this point.  If not, it's safer to bail out.  It's OK if this
+        # doesn't work in all conceivable situations, since some of them may
+        # require careful thought and decisions by a human administrator.
+        for src_tab, src_col, ref_tab, ref_col, updact, delact in references:
+            if (src_tab, src_col) in skip:
+                continue
+            result = store.execute("""
+                SELECT COUNT(*) FROM %(src_tab)s WHERE %(src_col)s = ?
+                """ % {
+                    'src_tab': src_tab,
+                    'src_col': src_col,
+                    },
+                (person.id,))
+            count = result.get_one()[0]
+            if count:
+                log.error(
+                    "User %s is still referenced by %d %s.%s values" %
+                    (person_name, count, src_tab, src_col))
+                has_references = True
+        if has_references:
+            return "User %s is still referenced" % person_name
+
+        return True
+
diff --git a/lib/lp/registry/tests/test_closeaccount.py b/lib/lp/registry/tests/test_closeaccount.py
new file mode 100644
index 0000000..0d4a1dc
--- /dev/null
+++ b/lib/lp/registry/tests/test_closeaccount.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# NOTE: The first line above must stay first; do not move the copyright
+# notice to the top.  See http://www.python.org/dev/peps/pep-0263/.
+#
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for Close Account Celery Job."""
+
+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.model.closeaccount import ICloseAccountJobSource
+from lp.services.job.interfaces.job import JobStatus
+from lp.testing import TestCaseWithFactory
+from lp.services.job.runner import JobRunner
+
+from lp.testing.dbuser import dbuser
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    )
+from lp.services.config import config
+
+
+class TestGitRepositoryRescan(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_close_account_job_nonexistent_username(self):
+        # The job completes with the expected exception logged:
+        # states that the user does not exist in LP
+        job_source = getUtility(ICloseAccountJobSource)
+        jobs = list(job_source.iterReady())
+
+        # at this point we have no jobs
+        self.assertEqual([], jobs)
+
+        getUtility(ICloseAccountJobSource).create('nonexistent_username')
+        jobs = list(job_source.iterReady())
+        jobs[0] = removeSecurityProxy(jobs[0])
+        with dbuser(config.ICloseAccountJobSource.dbuser):
+            JobRunner(jobs).runAll()
+
+        self.assertEqual(JobStatus.COMPLETED, jobs[0].status)
+        self.assertEqual(u'User nonexistent_username does not exist',
+                         jobs[0].context.exception)
+
+    def test_close_account_job_valid_username(self):
+        # The job completes and the username is now anonymized
+        user_to_delete = self.factory.makePerson(name='delete-me')
+        job_source = getUtility(ICloseAccountJobSource)
+        jobs = list(job_source.iterReady())
+
+        # at this point we have no jobs
+        self.assertEqual([], jobs)
+
+        getUtility(ICloseAccountJobSource).create(user_to_delete.name)
+        jobs = list(job_source.iterReady())
+        jobs[0] = removeSecurityProxy(jobs[0])
+        with dbuser(config.ICloseAccountJobSource.dbuser):
+            JobRunner(jobs).runAll()
+
+        self.assertEqual(JobStatus.COMPLETED, jobs[0].status)
+        self.assertIsNone(jobs[0].context.exception)
+        person = removeSecurityProxy(
+            getUtility(IPersonSet).getByName(user_to_delete.name))
+        self.assertEqual(person.name, u'removed%d' % user_to_delete.id)
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index db22192..1ee193e 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1992,6 +1992,11 @@ link: IBranchMergeProposalJobSource
 module: lp.services.webhooks.interfaces
 dbuser: webhookrunner
 
+[ICloseAccountJobSource]
+module: lp.registry.model.closeaccount
+dbuser: launchpad
+crontab_group: FREQUENT
+
 [job_runner_queues]
 # The names of all queues.
 queues: launchpad_job launchpad_job_slow bzrsyncd_job bzrsyncd_job_slow branch_write_job branch_write_job_slow celerybeat
diff --git a/lib/lp/services/database/locking.py b/lib/lp/services/database/locking.py
index 266f971..c7f1a27 100644
--- a/lib/lp/services/database/locking.py
+++ b/lib/lp/services/database/locking.py
@@ -44,6 +44,11 @@ class LockType(DBEnumeratedType):
         Package copy.
         """)
 
+    CLOSE_ACCOUNT = DBItem(3, """Close account.
+
+        Close account.
+        """)
+
 
 @contextmanager
 def try_advisory_lock(lock_type, lock_id, store):