Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an endpoint to delete a client #248

Merged
merged 1 commit into from
Nov 23, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docker/integtest/1-register-client.sh
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 # pragma: no cover


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
Original file line number Diff line number Diff line change
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 # pragma: no cover


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
Original file line number Diff line number Diff line change
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
25 changes: 22 additions & 3 deletions tests/opwen_email_server/services/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,19 +187,38 @@ 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'))
self.assertFalse(self._auth.is_owner('unknown-domain', 'owner'))

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'))
Loading