From 26ccb61a0892123d0d407937d3d49210d4f123c8 Mon Sep 17 00:00:00 2001 From: Michael Tremer Date: Wed, 28 Jun 2023 09:55:18 +0000 Subject: [PATCH] accounts: Add Bugzilla API to delete accounts in Bugzilla Signed-off-by: Michael Tremer --- Makefile.am | 1 + src/backend/accounts.py | 67 +++++++++++++++++++++ src/backend/base.py | 3 + src/backend/bugzilla.py | 126 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 src/backend/bugzilla.py diff --git a/Makefile.am b/Makefile.am index 59a5cf8d..02cb985d 100644 --- a/Makefile.am +++ b/Makefile.am @@ -51,6 +51,7 @@ backend_PYTHON = \ src/backend/accounts.py \ src/backend/base.py \ src/backend/blog.py \ + src/backend/bugzilla.py \ src/backend/campaigns.py \ src/backend/countries.py \ src/backend/database.py \ diff --git a/src/backend/accounts.py b/src/backend/accounts.py index 89b35595..fffd692f 100644 --- a/src/backend/accounts.py +++ b/src/backend/accounts.py @@ -1,6 +1,7 @@ #!/usr/bin/python # encoding: utf-8 +import asyncio import base64 import datetime import hashlib @@ -607,6 +608,20 @@ class Accounts(Object): # Cleanup expired account password resets self.db.execute("DELETE FROM account_password_resets WHERE expires_at <= NOW()") + async def _delete(self, *args, **kwargs): + """ + Deletes given users + """ + # Who is deleting? + who = self.get_by_uid("ms") + + for uid in args: + account = self.get_by_uid(uid) + + # Delete the account + with self.db.transaction(): + await account.delete(who) + # Discourse def decode_discourse_payload(self, payload, signature): @@ -849,6 +864,43 @@ class Account(LDAPObject): def name(self): return self._get_string("cn") + # Delete + + async def delete(self, user): + """ + Deletes this user + """ + # Check if this user can be deleted + if not self.can_be_deleted(user): + raise RuntimeError("Cannot delete user %s" % self) + + async with asyncio.TaskGroup() as tasks: + t = datetime.datetime.now() + + # Disable this account on Bugzilla + tasks.create_task( + self._disable_on_bugzilla("Deleted by %s, %s" % (user, t)), + ) + + # XXX Delete on Discourse + + # XXX Delete on LDAP + + def can_be_deleted(self, user): + """ + Return True if the user can be deleted by user + """ + # Check permissions + if not self.can_be_managed_by(user): + return False + + # Cannot delete shell users + if self.has_shell(): + return False + + # Looks okay + return True + # Nickname def get_nickname(self): @@ -1258,6 +1310,21 @@ class Account(LDAPObject): set_contents_to_promotional_emails, ) + # Bugzilla + + async def _disable_on_bugzilla(self, text=None): + """ + Disables the user on Bugzilla + """ + user = await self.backend.bugzilla.get_user(self.email) + + # Do nothing if the user does not exist + if not user: + return + + # Disable the user + await user.disable(text) + class StopForumSpam(Object): def init(self, email, address): diff --git a/src/backend/base.py b/src/backend/base.py index 476c2698..a4285e11 100644 --- a/src/backend/base.py +++ b/src/backend/base.py @@ -9,6 +9,7 @@ import tornado.httpclient from . import accounts from . import blog +from . import bugzilla from . import campaigns from . import database from . import fireinfo @@ -61,6 +62,7 @@ class Backend(object): # Initialize backend modules. self.accounts = accounts.Accounts(self) + self.bugzilla = bugzilla.Bugzilla(self) self.fireinfo = fireinfo.Fireinfo(self) self.iuse = iuse.IUse(self) self.mirrors = mirrors.Mirrors(self) @@ -132,6 +134,7 @@ class Backend(object): async def run_task(self, task, *args, **kwargs): tasks = { + "accounts:delete" : self.accounts._delete, "announce-blog-posts" : self.blog.announce, "check-mirrors" : self.mirrors.check_all, "check-spam" : self.accounts.check_spam, diff --git a/src/backend/bugzilla.py b/src/backend/bugzilla.py new file mode 100644 index 00000000..0680bca3 --- /dev/null +++ b/src/backend/bugzilla.py @@ -0,0 +1,126 @@ +#!/usr/bin/python3 + +import json +import urllib.parse + +from . import misc +from .decorators import * + +class BugzillaError(Exception): + pass + +class Bugzilla(misc.Object): + def init(self, api_key=None): + if api_key is None: + api_key = self.settings.get("bugzilla-api-key") + + # Store the API key + self.api_key = api_key + + @property + def url(self): + """ + Returns the base URL of a Bugzilla instance + """ + return self.settings.get("bugzilla-url") + + def make_url(self, *args, **kwargs): + """ + Composes a URL based on the base URL + """ + url = urllib.parse.urljoin(self.url, *args) + + # Append any query arguments + if kwargs: + url = "%s?%s" % (url, urllib.parse.urlencode(kwargs)) + + return url + + async def _request(self, method, url, data=None): + if data is None: + data = {} + + # Headers + headers = { + # Authenticate all requests + "X-BUGZILLA-API-KEY" : self.api_key, + } + + # Make the URL + url = self.make_url(url) + + # Fallback authentication because some API endpoints + # do not accept the API key in the header + data |= { "api_key" : self.api_key } + + # Encode body + body = None + + # For GET requests, append query arguments + if method == "GET": + if data: + url = "%s?%s" % (url, urllib.parse.urlencode(data)) + + # For POST/PUT encode all arguments as JSON + elif method in ("POST", "PUT"): + headers |= { + "Content-Type" : "application/json", + } + + body = json.dumps(data) + + # Send the request and wait for a response + res = await self.backend.http_client.fetch( + url, method=method, headers=headers, body=body) + + # Decode JSON response + body = json.loads(res.body) + + # Check for any errors + if "error" in body: + # Fetch code and message + code, message = body.get("code"), body.get("message") + + # Handle any so far unhandled errors + raise BugzillaError(message) + + # Return an empty response + return body + + async def get_user(self, uid): + """ + Fetches a user from Bugzilla + """ + response = await self._request("GET", "/rest/user/%s" % uid) + + # Return the user object + for data in response.get("users"): + return User(self.backend, data) + + + +class User(misc.Object): + def init(self, data): + self.data = data + + @property + def id(self): + return self.data.get("id") + + async def _update(self, **kwargs): + # Send the request + await self.backend.bugzilla._request("PUT", "/rest/user/%s" % self.id, **kwargs) + + # XXX apply changes to the User object? + + async def disable(self, text=None): + """ + Disables this user + """ + if not text: + text = "DISABLED" + + # Update the user + await self._update(data={ + "login_denied_text" : text, + }) -- 2.39.2