Add API to manage cloud accounts (#34)
This PR adds API endpoints and a CLI to create cloud accounts and to purge accounts of expired members. Co-authored-by: Max Erenberg <> Reviewed-on: #34 Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca> Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>pull/35/head
parent
798510511f
commit
ac98aaf38d
@ -0,0 +1,46 @@ |
||||
import click |
||||
from zope import component |
||||
|
||||
from ceo_common.interfaces import IConfig |
||||
|
||||
from ..utils import http_post |
||||
from .utils import handle_sync_response |
||||
|
||||
|
||||
@click.group(short_help='Perform operations on the CSC cloud') |
||||
def cloud(): |
||||
pass |
||||
|
||||
|
||||
@cloud.group(short_help='Manage your cloud account') |
||||
def account(): |
||||
pass |
||||
|
||||
|
||||
@account.command(short_help='Activate your cloud account') |
||||
def activate(): |
||||
cfg = component.getUtility(IConfig) |
||||
base_domain = cfg.get('base_domain') |
||||
|
||||
resp = http_post('/api/cloud/accounts/create') |
||||
handle_sync_response(resp) |
||||
lines = [ |
||||
'Congratulations! Your cloud account has been activated.', |
||||
f'You may now login into https://cloud.{base_domain} with your CSC credentials.', |
||||
"Make sure to enter 'Members' for the domain (no quotes).", |
||||
] |
||||
for line in lines: |
||||
click.echo(line) |
||||
|
||||
|
||||
@cloud.group(short_help='Manage cloud accounts') |
||||
def accounts(): |
||||
pass |
||||
|
||||
|
||||
@accounts.command(short_help='Purge expired cloud accounts') |
||||
def purge(): |
||||
resp = http_post('/api/cloud/accounts/purge') |
||||
result = handle_sync_response(resp) |
||||
click.echo('Accounts to be deleted: ' + ','.join(result['accounts_to_be_deleted'])) |
||||
click.echo('Accounts which were deleted: ' + ','.join(result['accounts_deleted'])) |
@ -0,0 +1,23 @@ |
||||
from typing import Dict |
||||
|
||||
from zope.interface import Interface |
||||
|
||||
from .IUser import IUser |
||||
|
||||
|
||||
class ICloudService(Interface): |
||||
"""Performs operations on the CSC Cloud.""" |
||||
|
||||
def create_account(user: IUser): |
||||
""" |
||||
Activate an LDAP account in CloudStack for the given user. |
||||
""" |
||||
|
||||
def purge_accounts() -> Dict: |
||||
""" |
||||
Delete CloudStack accounts which correspond to expired CSC accounts. |
||||
A warning message will be emailed to users one week before their |
||||
cloud account is deleted. |
||||
Another message will be emailed to the users after their cloud account |
||||
has been deleted. |
||||
""" |
@ -0,0 +1,7 @@ |
||||
import datetime |
||||
|
||||
|
||||
def get_current_datetime() -> datetime.datetime: |
||||
# We place this in a separate function so that we can mock it out |
||||
# in our unit tests. |
||||
return datetime.datetime.now() |
@ -0,0 +1,24 @@ |
||||
from flask import Blueprint |
||||
from zope import component |
||||
|
||||
from .utils import requires_authentication_no_realm, authz_restrict_to_syscom |
||||
from ceo_common.interfaces import ICloudService, ILDAPService |
||||
|
||||
bp = Blueprint('cloud', __name__) |
||||
|
||||
|
||||
@bp.route('/accounts/create', methods=['POST']) |
||||
@requires_authentication_no_realm |
||||
def create_account(auth_user: str): |
||||
cloud_srv = component.getUtility(ICloudService) |
||||
ldap_srv = component.getUtility(ILDAPService) |
||||
user = ldap_srv.get_user(auth_user) |
||||
cloud_srv.create_account(user) |
||||
return {'status': 'OK'} |
||||
|
||||
|
||||
@bp.route('/accounts/purge', methods=['POST']) |
||||
@authz_restrict_to_syscom |
||||
def purge_accounts(): |
||||
cloud_srv = component.getUtility(ICloudService) |
||||
return cloud_srv.purge_accounts() |
@ -0,0 +1,174 @@ |
||||
from base64 import b64encode |
||||
import datetime |
||||
import hashlib |
||||
import hmac |
||||
import json |
||||
import os |
||||
from typing import Dict, List |
||||
from urllib.parse import quote |
||||
|
||||
import requests |
||||
from zope import component |
||||
from zope.interface import implementer |
||||
|
||||
from ceo_common.errors import InvalidMembershipError, CloudStackAPIError |
||||
from ceo_common.logger_factory import logger_factory |
||||
from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \ |
||||
IMailService |
||||
from ceo_common.model import Term |
||||
import ceo_common.utils as utils |
||||
|
||||
logger = logger_factory(__name__) |
||||
|
||||
|
||||
@implementer(ICloudService) |
||||
class CloudService: |
||||
def __init__(self): |
||||
cfg = component.getUtility(IConfig) |
||||
self.api_key = cfg.get('cloudstack_api_key') |
||||
self.secret_key = cfg.get('cloudstack_secret_key') |
||||
self.base_url = cfg.get('cloudstack_base_url') |
||||
self.members_domain = 'Members' |
||||
|
||||
state_dir = '/run/ceod' |
||||
if not os.path.isdir(state_dir): |
||||
os.mkdir(state_dir) |
||||
self.pending_deletions_file = os.path.join(state_dir, 'cloudstack_pending_account_deletions.json') |
||||
|
||||
def _create_url(self, params: Dict[str, str]) -> str: |
||||
# See https://docs.cloudstack.apache.org/en/latest/developersguide/dev.html#the-cloudstack-api |
||||
if 'apiKey' not in params and 'apikey' not in params: |
||||
params['apiKey'] = self.api_key |
||||
params['response'] = 'json' |
||||
request_str = '&'.join( |
||||
key + '=' + quote(val) |
||||
for key, val in params.items() |
||||
) |
||||
sig_str = '&'.join( |
||||
key.lower() + '=' + quote(val).lower() |
||||
for key, val in sorted(params.items()) |
||||
) |
||||
sig = hmac.new(self.secret_key.encode(), sig_str.encode(), hashlib.sha1).digest() |
||||
encoded_sig = b64encode(sig).decode() |
||||
url = self.base_url + '?' + request_str + '&signature=' + quote(encoded_sig) |
||||
return url |
||||
|
||||
def _get_domain_id(self, domain_name: str) -> str: |
||||
url = self._create_url({ |
||||
'command': 'listDomains', |
||||
'details': 'min', |
||||
'name': domain_name, |
||||
}) |
||||
resp = requests.get(url) |
||||
resp.raise_for_status() |
||||
d = resp.json()['listdomainsresponse'] |
||||
assert d['count'] == 1, 'there should be one domain found' |
||||
return d['domain'][0]['id'] |
||||
|
||||
def _get_all_accounts(self, domain_id: str) -> List[Dict]: |
||||
url = self._create_url({ |
||||
'command': 'listAccounts', |
||||
'domainid': domain_id, |
||||
'details': 'min', |
||||
}) |
||||
resp = requests.get(url) |
||||
resp.raise_for_status() |
||||
d = resp.json()['listaccountsresponse'] |
||||
if 'account' not in d: |
||||
# The API returns an empty dict if there are no accounts |
||||
return [] |
||||
return d['account'] |
||||
|
||||
def _delete_account(self, account_id: str): |
||||
url = self._create_url({ |
||||
'command': 'deleteAccount', |
||||
'id': account_id, |
||||
}) |
||||
resp = requests.post(url) |
||||
resp.raise_for_status() |
||||
|
||||
def create_account(self, user: IUser): |
||||
if not user.membership_is_valid(): |
||||
raise InvalidMembershipError() |
||||
domain_id = self._get_domain_id(self.members_domain) |
||||
|
||||
url = self._create_url({ |
||||
'command': 'ldapCreateAccount', |
||||
'accounttype': '0', |
||||
'domainid': domain_id, |
||||
'username': user.uid, |
||||
}) |
||||
resp = requests.post(url) |
||||
d = resp.json()['createaccountresponse'] |
||||
if not resp.ok: |
||||
raise CloudStackAPIError(d['errortext']) |
||||
|
||||
def purge_accounts(self) -> Dict: |
||||
accounts_deleted = [] |
||||
accounts_to_be_deleted = [] |
||||
result = { |
||||
'accounts_deleted': accounts_deleted, |
||||
'accounts_to_be_deleted': accounts_to_be_deleted, |
||||
} |
||||
|
||||
current_term = Term.current() |
||||
beginning_of_term = current_term.to_datetime() |
||||
now = utils.get_current_datetime() |
||||
delta = now - beginning_of_term |
||||
if delta.days < 30: |
||||
# one-month grace period |
||||
return result |
||||
|
||||
ldap_srv = component.getUtility(ILDAPService) |
||||
mail_srv = component.getUtility(IMailService) |
||||
domain_id = self._get_domain_id(self.members_domain) |
||||
accounts = self._get_all_accounts(domain_id) |
||||
|
||||
if os.path.isfile(self.pending_deletions_file): |
||||
state = json.load(open(self.pending_deletions_file)) |
||||
last_check = datetime.datetime.fromtimestamp(state['timestamp']) |
||||
delta = now - last_check |
||||
if delta.days < 7: |
||||
logger.debug( |
||||
'Skipping account purge because less than one week has ' |
||||
'passed since the warning emails were sent out' |
||||
) |
||||
accounts_to_be_deleted.extend(state['accounts_to_be_deleted']) |
||||
return result |
||||
username_to_account_id = { |
||||
account['name']: account['id'] |
||||
for account in accounts |
||||
} |
||||
for username in state['accounts_to_be_deleted']: |
||||
if username not in username_to_account_id: |
||||
continue |
||||
user = ldap_srv.get_user(username) |
||||
if user.membership_is_valid(): |
||||
continue |
||||
account_id = username_to_account_id[username] |
||||
self._delete_account(account_id) |
||||
accounts_deleted.append(username) |
||||
mail_srv.send_cloud_account_has_been_deleted_message(user) |
||||
logger.info(f'Deleted cloud account for {username}') |
||||
os.unlink(self.pending_deletions_file) |
||||
return result |
||||
|
||||
state = { |
||||
'timestamp': int(now.timestamp()), |
||||
'accounts_to_be_deleted': accounts_to_be_deleted, |
||||
} |
||||
for account in accounts: |
||||
username = account['name'] |
||||
account_id = account['id'] |
||||
user = ldap_srv.get_user(username) |
||||
if user.membership_is_valid(): |
||||
continue |
||||
accounts_to_be_deleted.append(username) |
||||
mail_srv.send_cloud_account_will_be_deleted_message(user) |
||||
logger.info( |
||||
f'A warning email was sent to {username} because their ' |
||||
'cloud account will be deleted' |
||||
) |
||||
if accounts_to_be_deleted: |
||||
json.dump(state, open(self.pending_deletions_file, 'w')) |
||||
return result |
@ -0,0 +1,14 @@ |
||||
Hello {{ user.given_name }}, |
||||
|
||||
This is an automated message from ceo, the CSC Electronic Office. |
||||
|
||||
Your club membership has expired, so your CSC Cloud account |
||||
has been deleted. If you decide to renew your membership, you |
||||
may create a new cloud account, but it will not have any of the |
||||
resources from your old cloud account. |
||||
|
||||
If you have any questions or concerns, please contact the Systems |
||||
Committee: syscom@csclub.uwaterloo.ca |
||||
|
||||
Best regards, |
||||
ceo |
@ -0,0 +1,18 @@ |
||||
Hello {{ user.given_name }}, |
||||
|
||||
This is an automated message from ceo, the CSC Electronic Office. |
||||
|
||||
Your club membership has expired, and you have an active account in |
||||
the CSC Cloud (https://cloud.csclub.uwaterloo.ca). All of your cloud |
||||
resources (VMs, templates, DNS records, etc.) will be permanently |
||||
deleted if your membership is not renewed in one week's time. |
||||
|
||||
If you wish to keep your cloud resources, please renew your club |
||||
membership before next week. If you do not wish to keep your cloud |
||||
resources, then you may safely ignore this message. |
||||
|
||||
If you have any questions or concerns, please contact the Systems |
||||
Committee: syscom@csclub.uwaterloo.ca |
||||
|
||||
Best regards, |
||||
ceo |
@ -0,0 +1,160 @@ |
||||
from uuid import uuid4 |
||||
|
||||
from aiohttp import web |
||||
|
||||
from .MockHTTPServerBase import MockHTTPServerBase |
||||
|
||||
|
||||
def gen_uuid(): |
||||
return str(uuid4()) |
||||
|
||||
|
||||
class MockCloudStackServer(MockHTTPServerBase): |
||||
def __init__(self, port=8080): |
||||
routes = [ |
||||
web.get('/client/api', self.generic_handler), |
||||
web.post('/client/api', self.generic_handler), |
||||
# for debugging purposes |
||||
web.get('/reset', self.reset_handler), |
||||
web.post('/reset', self.reset_handler), |
||||
] |
||||
super().__init__(port, routes) |
||||
|
||||
self.users_by_accountid = {} |
||||
self.users_by_username = {} |
||||
|
||||
def clear(self): |
||||
self.users_by_accountid.clear() |
||||
self.users_by_username.clear() |
||||
|
||||
async def reset_handler(self, request): |
||||
self.clear() |
||||
return web.Response(text='OK\n') |
||||
|
||||
def _add_user(self, username: str): |
||||
account_id = gen_uuid() |
||||
user_id = gen_uuid() |
||||
user = { |
||||
"id": user_id, |
||||
"username": username, |
||||
"firstname": "Calum", |
||||
"lastname": "Dalek", |
||||
"email": username + "@csclub.internal", |
||||
"created": "2021-11-20T11:08:24-0500", |
||||
"state": "enabled", |
||||
"account": username, |
||||
"accounttype": 0, |
||||
"usersource": "ldap", |
||||
"roleid": "24422759-45de-11ec-b585-32ee6075b19b", |
||||
"roletype": "User", |
||||
"rolename": "User", |
||||
"domainid": "4d2a4a98-b1b4-47a8-ab8f-7e175013a0f0", |
||||
"domain": "Members", |
||||
"accountid": account_id, |
||||
"iscallerchilddomain": False, |
||||
"isdefault": False |
||||
} |
||||
self.users_by_accountid[account_id] = user |
||||
self.users_by_username[username] = user |
||||
return user |
||||
|
||||
def _delete_user(self, account_id: str): |
||||
user = self.users_by_accountid[account_id] |
||||
username = user['username'] |
||||
del self.users_by_accountid[account_id] |
||||
del self.users_by_username[username] |
||||
|
||||
def _account_from_username(self, username: str): |
||||
user = self.users_by_username[username] |
||||
return { |
||||
"id": user['accountid'], |
||||
"name": username, |
||||
"accounttype": 0, |
||||
"roleid": "24422759-45de-11ec-b585-32ee6075b19b", |
||||
"roletype": "User", |
||||
"rolename": "User", |
||||
"domainid": "4d2a4a98-b1b4-47a8-ab8f-7e175013a0f0", |
||||
"domain": "Members", |
||||
"domainpath": "ROOT/Members", |
||||
"state": "enabled", |
||||
"user": [user], |
||||
"isdefault": False, |
||||
"groups": [] |
||||
} |
||||
|
||||
async def generic_handler(self, request): |
||||
command = request.query['command'] |
||||
if command == 'listDomains': |
||||
return web.json_response({ |
||||
"listdomainsresponse": { |
||||
"count": 1, |
||||
"domain": [{ |
||||
"id": "4d2a4a98-b1b4-47a8-ab8f-7e175013a0f0", |
||||
"name": "Members", |
||||
"level": 1, |
||||
"parentdomainid": "f0f8263c-45dd-11ec-b585-32ee6075b19b", |
||||
"parentdomainname": "ROOT", |
||||
"haschild": False, |
||||
"path": "ROOT/Members", |
||||
"state": "Active", |
||||
"secondarystoragetotal": 0.0 |
||||
}] |
||||
} |
||||
}) |
||||
elif command == 'ldapCreateAccount': |
||||
username = request.query['username'] |
||||
if username in self.users_by_username: |
||||
return web.json_response({ |
||||
"createaccountresponse": { |
||||
"uuidList": [], |
||||
"errorcode": 530, |
||||
"cserrorcode": 4250, |
||||
"errortext": f"The user {username} already exists in domain 2" |
||||
} |
||||
}, status=530) |
||||
self._add_user(username) |
||||
return web.json_response({ |
||||
"createaccountresponse": { |
||||
"account": self._account_from_username(username), |
||||
} |
||||
}) |
||||
elif command == 'listUsers': |
||||
users = list(self.users_by_username.values()) |
||||
return web.json_response({ |
||||
'listusersresponse': { |
||||
'count': len(users), |
||||
'user': users, |
||||
} |
||||
}) |
||||
elif command == 'listAccounts': |
||||
usernames = list(self.users_by_username.keys()) |
||||
return web.json_response({ |
||||
'listaccountsresponse': { |
||||
'count': len(usernames), |
||||
'account': [ |
||||
self._account_from_username(username) |
||||
for username in usernames |
||||
] |
||||
} |
||||
}) |
||||
elif command == 'deleteAccount': |
||||
account_id = request.query['id'] |
||||
self._delete_user(account_id) |
||||
return web.json_response({ |
||||
'deleteaccountresponse': { |
||||
'jobid': gen_uuid() |
||||
} |
||||
}) |
||||
else: |
||||
return web.json_response({ |
||||
"errorresponse": { |
||||
"uuidList": [], |
||||
"errorcode": 401, |
||||
"errortext": "unable to verify user credentials and/or request signature" |
||||
} |
||||
}, status=401) |
||||
|
||||
|
||||
if __name__ == '__main__': |
||||
server = MockCloudStackServer() |
||||
server.start() |
@ -0,0 +1,29 @@ |
||||
from abc import ABC |
||||
import asyncio |
||||
from threading import Thread |
||||
from typing import List |
||||
|
||||
from aiohttp import web |
||||
|
||||
|
||||
class MockHTTPServerBase(ABC): |
||||
def __init__(self, port: int, routes: List): |
||||
self.port = port |
||||
self.app = web.Application() |
||||
self.app.add_routes(routes) |
||||
self.runner = web.AppRunner(self.app) |
||||
self.loop = asyncio.new_event_loop() |
||||
|
||||
def _start_loop(self): |
||||
asyncio.set_event_loop(self.loop) |
||||
self.loop.run_until_complete(self.runner.setup()) |
||||
site = web.TCPSite(self.runner, '127.0.0.1', self.port) |
||||
self.loop.run_until_complete(site.start()) |
||||
self.loop.run_forever() |
||||
|
||||
def start(self): |
||||
t = Thread(target=self._start_loop) |
||||
t.start() |
||||
|
||||
def stop(self): |
||||
self.loop.call_soon_threadsafe(self.loop.stop) |
@ -1,2 +0,0 @@ |
||||
from .MockSMTPServer import MockSMTPServer |
||||
from .MockMailmanServer import MockMailmanServer |
@ -0,0 +1,28 @@ |
||||
from click.testing import CliRunner |
||||
|
||||
from ...utils import gssapi_token_ctx |
||||
from ceo.cli import cli |
||||
|
||||
|
||||
def test_cloud_account_activate(cli_setup, mock_cloud_server, new_user, cfg): |
||||
base_domain = cfg.get('base_domain') |
||||
mock_cloud_server.clear() |
||||
|
||||
runner = CliRunner() |
||||
with gssapi_token_ctx(new_user.uid): |
||||
result = runner.invoke(cli, ['cloud', 'account', 'activate']) |
||||
expected = ( |
||||
'Congratulations! Your cloud account has been activated.\n' |
||||
f'You may now login into https://cloud.{base_domain} with your CSC credentials.\n' |
||||
"Make sure to enter 'Members' for the domain (no quotes).\n" |
||||
) |
||||
assert result.exit_code == 0 |
||||
assert result.output == expected |
||||
|
||||
|
||||
def test_cloud_accounts_purge(cli_setup, mock_cloud_server): |
||||
mock_cloud_server.clear() |
||||
|
||||
runner = CliRunner() |
||||
result = runner.invoke(cli, ['cloud', 'accounts', 'purge']) |
||||
assert result.exit_code == 0 |
@ -0,0 +1,86 @@ |
||||
import datetime |
||||
import os |
||||
from unittest.mock import patch |
||||
|
||||
import ldap3 |
||||
|
||||
from ceo_common.model import Term |
||||
import ceo_common.utils as ceo_common_utils |
||||
|
||||
|
||||
def expire_member(user, ldap_conn): |
||||
most_recent_term = max(map(Term, user.terms)) |
||||
new_term = most_recent_term - 1 |
||||
changes = { |
||||
'term': [(ldap3.MODIFY_REPLACE, [str(new_term)])] |
||||
} |
||||
dn = user.ldap_srv.uid_to_dn(user.uid) |
||||
ldap_conn.modify(dn, changes) |
||||
|
||||
|
||||
def test_create_account(client, mock_cloud_server, new_user, ldap_conn): |
||||
uid = new_user.uid |
||||
mock_cloud_server.clear() |
||||
status, _ = client.post('/api/cloud/accounts/create', principal=uid) |
||||
assert status == 200 |
||||
assert uid in mock_cloud_server.users_by_username |
||||
|
||||
status, _ = client.post('/api/cloud/accounts/create', principal=uid) |
||||
assert status != 200 |
||||
|
||||
mock_cloud_server.clear() |
||||
expire_member(new_user, ldap_conn) |
||||
status, _ = client.post('/api/cloud/accounts/create', principal=uid) |
||||
assert status == 403 |
||||
|
||||
|
||||
def test_purge_accounts( |
||||
client, mock_cloud_server, cloud_srv, mock_mail_server, new_user, |
||||
ldap_conn, |
||||
): |
||||
uid = new_user.uid |
||||
mock_cloud_server.clear() |
||||
mock_mail_server.messages.clear() |
||||
accounts_deleted = [] |
||||
accounts_to_be_deleted = [] |
||||
if os.path.isfile(cloud_srv.pending_deletions_file): |
||||
os.unlink(cloud_srv.pending_deletions_file) |
||||
expected = { |
||||
'accounts_deleted': accounts_deleted, |
||||
'accounts_to_be_deleted': accounts_to_be_deleted, |
||||
} |
||||
current_term = Term.current() |
||||
beginning_of_term = current_term.to_datetime() |
||||
client.post('/api/cloud/accounts/create', principal=uid) |
||||
expire_member(new_user, ldap_conn) |
||||
with patch.object(ceo_common_utils, 'get_current_datetime') as now_mock: |
||||
# one-month grace period - account should not be deleted |
||||
now_mock.return_value = beginning_of_term + datetime.timedelta(days=1) |
||||
status, data = client.post('/api/cloud/accounts/purge') |
||||
assert status == 200 |
||||
assert data == expected |
||||
|
||||
# grace period has passed - user should be sent a warning |
||||
now_mock.return_value += datetime.timedelta(days=32) |
||||
accounts_to_be_deleted.append(new_user.uid) |
||||
status, data = client.post('/api/cloud/accounts/purge') |
||||
assert status == 200 |
||||
assert data == expected |
||||
assert os.path.isfile(cloud_srv.pending_deletions_file) |
||||
assert len(mock_mail_server.messages) == 1 |
||||
|
||||
# user still has one week left to renew their membership |
||||
status, data = client.post('/api/cloud/accounts/purge') |
||||
assert status == 200 |
||||
assert data == expected |
||||
|
||||
# one week has passed - the account can now be deleted |
||||
now_mock.return_value += datetime.timedelta(days=8) |
||||
accounts_to_be_deleted.clear() |
||||
accounts_deleted.append(new_user.uid) |
||||
status, data = client.post('/api/cloud/accounts/purge') |
||||
assert status == 200 |
||||
assert data == expected |
||||
assert new_user.uid not in mock_cloud_server.users_by_username |
||||
assert len(mock_mail_server.messages) == 2 |
||||
mock_mail_server.messages.clear() |
Loading…
Reference in new issue