Skip to content

Commit

Permalink
Add an endpoint to delete a client
Browse files Browse the repository at this point in the history
  • Loading branch information
c-w committed Nov 23, 2019
1 parent 4ae4d70 commit 2f799d4
Show file tree
Hide file tree
Showing 14 changed files with 329 additions and 26 deletions.
19 changes: 19 additions & 0 deletions docker/integtest/1-register-client.sh
Expand Up @@ -31,3 +31,22 @@ curl -fs \
-d '{"domain":"developer2.lokole.ca"}' \
"http://nginx:8888/api/email/register/" \
| tee "${out_dir}/register2.json"

# after creating a client, creating the same one again should fail but we should be able to delete it
curl -fs \
-H "Content-Type: application/json" \
-u "${REGISTRATION_CREDENTIALS}" \
-d '{"domain":"developer3.lokole.ca"}' \
"http://nginx:8888/api/email/register/"

if curl -fs \
-H "Content-Type: application/json" \
-u "${REGISTRATION_CREDENTIALS}" \
-d '{"domain":"developer3.lokole.ca"}' \
"http://nginx:8888/api/email/register/" \
; then echo "Was able to register a duplicate client" >&2; exit 5; fi

curl -fs \
-u "${REGISTRATION_CREDENTIALS}" \
-X DELETE \
"http://nginx:8888/api/email/register/developer3.lokole.ca"
29 changes: 28 additions & 1 deletion opwen_email_server/actions.py
Expand Up @@ -326,7 +326,7 @@ def _action(self, client, **auth_args): # type: ignore
self._setup_mailbox(client_id, domain)
self._setup_mx_records(domain)
self._client_storage.ensure_exists()
self._auth.insert(client_id, domain)
self._auth.insert(client_id, domain, auth_args.get('user'))

self.log_event(events.NEW_CLIENT_REGISTERED, {'domain': domain}) # noqa: E501 # yapf: disable
return {
Expand All @@ -337,6 +337,33 @@ def _action(self, client, **auth_args): # type: ignore
}


class DeleteClient(_Action):
def __init__(self, auth: AzureAuth, delete_mailbox: Callable[[str, str], None],
delete_mx_records: Callable[[str], None]):

self._auth = auth
self._delete_mailbox = delete_mailbox
self._delete_mx_records = delete_mx_records

def _action(self, domain, **auth_args): # type: ignore
if not is_lowercase(domain):
return 'domain must be lowercase', 400

client_id = self._auth.client_id_for(domain)
if client_id is None:
return 'client does not exist', 404

if not self._auth.is_owner(domain, auth_args.get('user')):
return 'client does not belong to the user', 403

self._delete_mailbox(client_id, domain)
self._delete_mx_records(domain)
self._auth.delete(client_id, domain)

self.log_event(events.CLIENT_DELETED, {'domain': domain}) # noqa: E501 # yapf: disable
return 'OK', 200


class CalculatePendingEmailsMetric(_Action):
def __init__(self, auth: AzureAuth, pending_factory: Callable[[str], AzureTextStorage]):

Expand Down
1 change: 1 addition & 0 deletions opwen_email_server/constants/events.py
@@ -1,5 +1,6 @@
from typing_extensions import Final # noqa: F401

CLIENT_DELETED = 'client_deleted' # type: Final
NEW_CLIENT_REGISTERED = 'new_client_registered' # type: Final
UNREGISTERED_CLIENT = 'unregistered_client' # type: Final
UNKNOWN_USER = 'unknown_user' # type: Final
Expand Down
3 changes: 2 additions & 1 deletion opwen_email_server/constants/sendgrid.py
@@ -1,6 +1,7 @@
from typing_extensions import Final # noqa: F401

MAILBOX_URL = 'https://api.sendgrid.com/v3/user/webhooks/parse/settings' # type: Final # noqa: E501 # yapf: disable
MAILBOX_CREATE_URL = 'https://api.sendgrid.com/v3/user/webhooks/parse/settings' # type: Final # noqa: E501 # yapf: disable
MAILBOX_DELETE_URL = 'https://api.sendgrid.com/v3/user/webhooks/parse/settings/{}' # type: Final # noqa: E501 # yapf: disable

INBOX_URL = 'https://mailserver.lokole.ca/api/email/sendgrid/{}' # type: Final

Expand Down
16 changes: 16 additions & 0 deletions opwen_email_server/integration/azure.py
Expand Up @@ -4,7 +4,9 @@
from opwen_email_server.constants import azure as constants
from opwen_email_server.constants.cache import PENDING_STORAGE_CACHE_SIZE
from opwen_email_server.services.auth import AzureAuth
from opwen_email_server.services.dns import DeleteMxRecords
from opwen_email_server.services.dns import SetupMxRecords
from opwen_email_server.services.sendgrid import DeleteSendgridMailbox
from opwen_email_server.services.sendgrid import SendSendgridEmail
from opwen_email_server.services.sendgrid import SetupSendgridMailbox
from opwen_email_server.services.storage import AzureFileStorage
Expand Down Expand Up @@ -60,6 +62,11 @@ def get_mailbox_setup() -> SetupSendgridMailbox:
return SetupSendgridMailbox(key=config.SENDGRID_KEY)


@singleton
def get_mailbox_deletion() -> DeleteSendgridMailbox:
return DeleteSendgridMailbox(key=config.SENDGRID_KEY)


@singleton
def get_mx_setup() -> SetupMxRecords:
return SetupMxRecords(
Expand All @@ -69,6 +76,15 @@ def get_mx_setup() -> SetupMxRecords:
)


@singleton
def get_mx_deletion() -> DeleteMxRecords:
return DeleteMxRecords(
account=config.DNS_ACCOUNT,
secret=config.DNS_SECRET,
provider=config.DNS_PROVIDER,
)


@singleton
def get_email_storage() -> AzureObjectStorage:
return AzureObjectStorage(
Expand Down
9 changes: 9 additions & 0 deletions opwen_email_server/integration/connexion.py
@@ -1,5 +1,6 @@
from opwen_email_server import config
from opwen_email_server.actions import CalculatePendingEmailsMetric
from opwen_email_server.actions import DeleteClient
from opwen_email_server.actions import DownloadClientEmails
from opwen_email_server.actions import Ping
from opwen_email_server.actions import ReceiveInboundEmail
Expand All @@ -8,7 +9,9 @@
from opwen_email_server.integration.azure import get_auth
from opwen_email_server.integration.azure import get_client_storage
from opwen_email_server.integration.azure import get_email_storage
from opwen_email_server.integration.azure import get_mailbox_deletion
from opwen_email_server.integration.azure import get_mailbox_setup
from opwen_email_server.integration.azure import get_mx_deletion
from opwen_email_server.integration.azure import get_mx_setup
from opwen_email_server.integration.azure import get_pending_storage
from opwen_email_server.integration.azure import get_raw_email_storage
Expand Down Expand Up @@ -43,6 +46,12 @@
setup_mx_records=get_mx_setup(),
)

client_delete = DeleteClient(
auth=get_auth(),
delete_mailbox=get_mailbox_deletion(),
delete_mx_records=get_mx_deletion(),
)

metrics_pending = CalculatePendingEmailsMetric(
auth=get_auth(),
pending_factory=get_pending_storage,
Expand Down
36 changes: 33 additions & 3 deletions opwen_email_server/services/auth.py
@@ -1,4 +1,5 @@
from functools import lru_cache
from json import JSONDecodeError
from typing import Callable
from typing import Dict
from typing import Iterable
Expand All @@ -14,6 +15,8 @@
from opwen_email_server.constants.cache import AUTH_DOMAIN_CACHE_SIZE
from opwen_email_server.services.storage import AzureTextStorage
from opwen_email_server.utils.log import LogMixin
from opwen_email_server.utils.serialization import from_json
from opwen_email_server.utils.serialization import to_json


class AnyOfBasicAuth(LogMixin):
Expand Down Expand Up @@ -137,18 +140,45 @@ class AzureAuth(LogMixin):
def __init__(self, storage: AzureTextStorage) -> None:
self._storage = storage

def insert(self, client_id: str, domain: str):
def insert(self, client_id: str, domain: str, owner: str):
self._storage.store_text(client_id, domain)
self._storage.store_text(domain, client_id)
self._storage.store_text(domain, to_json({'client_id': client_id, 'owner': owner}))
self.log_debug('Registered client %s at domain %s', client_id, domain)

def is_owner(self, domain: str, username: str) -> bool:
try:
raw_auth = self._storage.fetch_text(domain)
except ObjectDoesNotExistError:
self.log_debug('Unrecognized domain %s', domain)
return False

try:
auth = from_json(raw_auth)
except JSONDecodeError:
# fallback for clients registered before November 2019
self.log_debug('Unable to lookup owner for domain %s', domain)
return False

return auth.get('owner') == username

def delete(self, client_id: str, domain: str) -> bool:
self._storage.delete(domain)
self._storage.delete(client_id)
self._domain_for_cached.cache_clear()
return True

def client_id_for(self, domain: str) -> Optional[str]:
try:
client_id = self._storage.fetch_text(domain)
raw_auth = self._storage.fetch_text(domain)
except ObjectDoesNotExistError:
self.log_debug('Unrecognized domain %s', domain)
return None
else:
try:
client_id = from_json(raw_auth)['client_id']
except JSONDecodeError:
# fallback for clients registered before November 2019
client_id = raw_auth
self.log_debug('Domain %s has client %s', domain, client_id)
return client_id

Expand Down
22 changes: 20 additions & 2 deletions opwen_email_server/services/dns.py
@@ -1,5 +1,6 @@
from cached_property import cached_property
from libcloud.dns.base import DNSDriver
from libcloud.dns.base import Zone
from libcloud.dns.providers import get_driver
from libcloud.dns.types import Provider
from libcloud.dns.types import RecordType
Expand All @@ -8,7 +9,7 @@
from opwen_email_server.utils.log import LogMixin


class SetupMxRecords(LogMixin):
class _MxRecords(LogMixin):
def __init__(self, account: str, secret: str, provider: str) -> None:
self._account = account
self._secret = secret
Expand All @@ -30,11 +31,28 @@ def __call__(self, domain: str) -> None:

zone = next(zone for zone in self._driver.iterate_zones() if zone.domain == zone_name)

self._run(client_name, zone)

def _run(self, client_name: str, zone: Zone) -> None:
raise NotImplementedError


class DeleteMxRecords(_MxRecords):
def _run(self, client_name: str, zone: Zone) -> None:
record = next(record for record in self._driver.iterate_records(zone) if record.name == client_name)

self._driver.delete_record(record)

self.log_debug('Deleted MX records for client %s.%s', client_name, zone.domain)


class SetupMxRecords(_MxRecords):
def _run(self, client_name: str, zone: Zone) -> None:
self._driver.create_record(
zone=zone,
name=client_name,
type=RecordType.MX,
data=MX_RECORD,
)

self.log_debug('Set up MX records for %s', domain)
self.log_debug('Set up MX records for client %s.%s', client_name, zone.domain)
28 changes: 25 additions & 3 deletions opwen_email_server/services/sendgrid.py
Expand Up @@ -3,6 +3,7 @@

from cached_property import cached_property
from python_http_client import BadRequestsError
from requests import delete as http_delete
from requests import post as http_post
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Attachment
Expand All @@ -14,7 +15,8 @@
from sendgrid.helpers.mail import SandBoxMode

from opwen_email_server.constants.sendgrid import INBOX_URL
from opwen_email_server.constants.sendgrid import MAILBOX_URL
from opwen_email_server.constants.sendgrid import MAILBOX_CREATE_URL
from opwen_email_server.constants.sendgrid import MAILBOX_DELETE_URL
from opwen_email_server.utils.log import LogMixin
from opwen_email_server.utils.serialization import to_base64

Expand Down Expand Up @@ -119,7 +121,7 @@ def _create_attachment(cls, attachment: dict) -> Attachment:
)


class SetupSendgridMailbox(LogMixin):
class _SendgridManagement(LogMixin):
def __init__(self, key: str) -> None:
self._key = key

Expand All @@ -128,8 +130,28 @@ def __call__(self, client_id: str, domain: str) -> None:
self.log_warning('No key, skipping mailbox setup for %s', domain)
return

self._run(client_id, domain)

def _run(self, client_id: str, domain: str) -> None:
raise NotImplementedError


class DeleteSendgridMailbox(_SendgridManagement):
def _run(self, client_id: str, domain: str) -> None:
http_delete(
url=MAILBOX_DELETE_URL.format(domain),
headers={
'Authorization': f'Bearer {self._key}',
},
).raise_for_status()

self.log_debug('Deleted mailbox for %s', domain)


class SetupSendgridMailbox(_SendgridManagement):
def _run(self, client_id: str, domain: str) -> None:
http_post(
url=MAILBOX_URL,
url=MAILBOX_CREATE_URL,
json={
'hostname': domain,
'url': INBOX_URL.format(client_id),
Expand Down
25 changes: 25 additions & 0 deletions opwen_email_server/swagger/client-register.yaml
Expand Up @@ -31,6 +31,24 @@ paths:
security:
- basic: []

'/{domain}':
delete:
operationId: opwen_email_server.integration.connexion.client_delete
summary: Endpoint where Lokole clients can be deleted.
parameters:
- $ref: '#/parameters/Domain'
responses:
200:
description: The client was successfully deleted.
400:
description: The supplied client is malformed.
403:
description: The client does not belong to the user.
404:
description: The supplied client does not exist.
security:
- basic: []

securityDefinitions:
basic:
type: basic
Expand All @@ -46,6 +64,13 @@ parameters:
$ref: '#/definitions/RegistrationInfo'
required: true

Domain:
name: domain
description: Domain of the Lokole client.
in: path
type: string
required: true

definitions:

RegistrationInfo:
Expand Down
24 changes: 21 additions & 3 deletions tests/opwen_email_server/services/test_auth.py
Expand Up @@ -187,19 +187,37 @@ def test_with_correct_password(self):
class AzureAuthTests(TestCase):
def setUp(self):
self._folder = mkdtemp()
self._auth = AzureAuth(storage=AzureTextStorage(
self._storage = AzureTextStorage(
account=self._folder,
key='key',
container='auth',
provider='LOCAL',
))
)
self._auth = AzureAuth(storage=self._storage)

def tearDown(self):
rmtree(self._folder)

def test_inserts_and_retrieves_client(self):
self._auth.insert('client', 'domain')
self._auth.insert('client', 'domain', 'owner')
self.assertEqual(self._auth.domain_for('client'), 'domain')
self.assertEqual(self._auth.client_id_for('domain'), 'client')
self.assertIsNone(self._auth.domain_for('unknown-client'))
self.assertIsNone(self._auth.client_id_for('unknown-client'))
self.assertTrue(self._auth.is_owner('domain', 'owner'))
self.assertFalse(self._auth.is_owner('domain', 'unknown-user'))

def test_deletes_client(self):
self._auth.insert('client', 'domain', 'owner')
self.assertIsNotNone(self._auth.domain_for('client'))
self._auth.delete('client', 'domain')
self.assertIsNone(self._auth.domain_for('client'))

def test_inserts_and_retrieves_client_backwards_compatibility_pre_november_2019(self):
# emulate pre november 2019 version of self._auth.insert
self._storage.store_text('client', 'domain')
self._storage.store_text('domain', 'client')

self.assertEqual(self._auth.domain_for('client'), 'domain')
self.assertEqual(self._auth.client_id_for('domain'), 'client')
self.assertFalse(self._auth.is_owner('domain', 'owner'))

0 comments on commit 2f799d4

Please sign in to comment.