parent
0974a7471b
commit
6917247fdd
@ -0,0 +1,4 @@ |
||||
from .cli import cli |
||||
|
||||
if __name__ == '__main__': |
||||
cli(obj={}) |
@ -0,0 +1,43 @@ |
||||
import importlib.resources |
||||
import os |
||||
import socket |
||||
|
||||
import click |
||||
from zope import component |
||||
|
||||
from .krb_check import krb_check |
||||
from .members import members |
||||
from ceo_common.interfaces import IConfig, IHTTPClient |
||||
from ceo_common.model import Config, HTTPClient |
||||
|
||||
|
||||
@click.group() |
||||
@click.pass_context |
||||
def cli(ctx): |
||||
# ensure ctx exists and is a dict |
||||
ctx.ensure_object(dict) |
||||
|
||||
princ = krb_check() |
||||
user = princ[:princ.index('@')] |
||||
ctx.obj['user'] = user |
||||
|
||||
register_services() |
||||
|
||||
|
||||
cli.add_command(members) |
||||
|
||||
|
||||
def register_services(): |
||||
# Config |
||||
# This is a hack to determine if we're in the dev env or not |
||||
if socket.getfqdn().endswith('.csclub.internal'): |
||||
with importlib.resources.path('tests', 'ceo_dev.ini') as p: |
||||
config_file = p.__fspath__() |
||||
else: |
||||
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini') |
||||
cfg = Config(config_file) |
||||
component.provideUtility(cfg, IConfig) |
||||
|
||||
# HTTPService |
||||
http_client = HTTPClient() |
||||
component.provideUtility(http_client, IHTTPClient) |
@ -0,0 +1,28 @@ |
||||
import subprocess |
||||
|
||||
import gssapi |
||||
|
||||
|
||||
def krb_check(): |
||||
""" |
||||
Spawns a `kinit` process if no credentials are available or the |
||||
credentials have expired. |
||||
Returns the principal string 'user@REALM'. |
||||
""" |
||||
try: |
||||
creds = gssapi.Credentials(usage='initiate') |
||||
except gssapi.raw.misc.GSSError: |
||||
kinit() |
||||
creds = gssapi.Credentials(usage='initiate') |
||||
|
||||
try: |
||||
result = creds.inquire() |
||||
except gssapi.raw.exceptions.ExpiredCredentialsError: |
||||
kinit() |
||||
result = creds.inquire() |
||||
|
||||
return str(result.name) |
||||
|
||||
|
||||
def kinit(): |
||||
subprocess.run(['kinit'], check=True) |
@ -0,0 +1,206 @@ |
||||
import socket |
||||
import sys |
||||
from typing import Dict |
||||
|
||||
import click |
||||
from zope import component |
||||
|
||||
from .utils import http_post, http_get, http_patch, http_delete, \ |
||||
handle_stream_response, handle_sync_response, print_colon_kv, \ |
||||
get_failed_operations |
||||
from ceo_common.interfaces import IConfig |
||||
from ceo_common.utils import get_current_term, add_term, get_max_term |
||||
from ceod.transactions.members import ( |
||||
AddMemberTransaction, |
||||
DeleteMemberTransaction, |
||||
) |
||||
|
||||
|
||||
@click.group() |
||||
def members(): |
||||
pass |
||||
|
||||
|
||||
@members.command(short_help='Add a new member or club rep') |
||||
@click.argument('username') |
||||
@click.option('--cn', help='Full name', prompt='Full name') |
||||
@click.option('--program', required=False, help='Academic program') |
||||
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100), |
||||
help='Number of terms to add', prompt='Number of terms') |
||||
@click.option('--clubrep', is_flag=True, default=False, |
||||
help='Add non-member terms instead of member terms') |
||||
@click.option('--forwarding-address', required=False, |
||||
help=('Forwarding address to set in ~/.forward. ' |
||||
'Default is UW address. ' |
||||
'Set to the empty string to disable forwarding.')) |
||||
def add(username, cn, program, num_terms, clubrep, forwarding_address): |
||||
cfg = component.getUtility(IConfig) |
||||
uw_domain = cfg.get('uw_domain') |
||||
|
||||
current_term = get_current_term() |
||||
terms = [current_term] |
||||
for _ in range(1, num_terms): |
||||
term = add_term(terms[-1]) |
||||
terms.append(term) |
||||
if forwarding_address is None: |
||||
forwarding_address = username + '@' + uw_domain |
||||
|
||||
click.echo("The following user will be created:") |
||||
lines = [ |
||||
('uid', username), |
||||
('cn', cn), |
||||
] |
||||
if program is not None: |
||||
lines.append(('program', program)) |
||||
if clubrep: |
||||
lines.append(('non-member terms', ','.join(terms))) |
||||
else: |
||||
lines.append(('member terms', ','.join(terms))) |
||||
if forwarding_address != '': |
||||
lines.append(('forwarding address', forwarding_address)) |
||||
print_colon_kv(lines) |
||||
|
||||
click.confirm('Do you want to continue?', abort=True) |
||||
|
||||
body = { |
||||
'uid': username, |
||||
'cn': cn, |
||||
} |
||||
if program is not None: |
||||
body['program'] = program |
||||
if clubrep: |
||||
body['terms'] = terms |
||||
else: |
||||
body['non_member_terms'] = terms |
||||
if forwarding_address != '': |
||||
body['forwarding_addresses'] = [forwarding_address] |
||||
resp = http_post('/api/members', json=body) |
||||
data = handle_stream_response(resp, AddMemberTransaction.operations) |
||||
result = data[-1]['result'] |
||||
print_user_lines(result) |
||||
|
||||
failed_operations = get_failed_operations(data) |
||||
if 'send_welcome_message' in failed_operations: |
||||
click.echo(click.style( |
||||
'Warning: welcome message was not sent. You now need to manually ' |
||||
'send the user their password.', fg='yellow')) |
||||
|
||||
|
||||
def print_user_lines(result: Dict): |
||||
"""Pretty-print a user JSON response.""" |
||||
lines = [ |
||||
('uid', result['uid']), |
||||
('cn', result['cn']), |
||||
('program', result.get('program', 'Unknown')), |
||||
('UID number', result['uid_number']), |
||||
('GID number', result['gid_number']), |
||||
('login shell', result['login_shell']), |
||||
('home directory', result['home_directory']), |
||||
('is a club', result['is_club']), |
||||
] |
||||
if 'forwarding_addresses' in result: |
||||
lines.append(('forwarding addresses', ','.join(result['forwarding_addresses']))) |
||||
if 'terms' in result: |
||||
lines.append(('terms', ','.join(result['terms']))) |
||||
if 'non_member_terms' in result: |
||||
lines.append(('non-member terms', ','.join(result['non_member_terms']))) |
||||
if 'password' in result: |
||||
lines.append(('password', result['password'])) |
||||
print_colon_kv(lines) |
||||
|
||||
|
||||
@members.command(short_help='Get info about a user') |
||||
@click.argument('username') |
||||
def get(username): |
||||
resp = http_get('/api/members/' + username) |
||||
result = handle_sync_response(resp) |
||||
print_user_lines(result) |
||||
|
||||
|
||||
@members.command(short_help="Replace a user's login shell or forwarding addresses") |
||||
@click.argument('username') |
||||
@click.option('--login-shell', required=False, help='Login shell') |
||||
@click.option('--forwarding-addresses', required=False, |
||||
help='Comma-separated list of forwarding addresses') |
||||
def modify(username, login_shell, forwarding_addresses): |
||||
if login_shell is None and forwarding_addresses is None: |
||||
click.echo('Nothing to do.') |
||||
sys.exit() |
||||
operations = [] |
||||
body = {} |
||||
if login_shell is not None: |
||||
body['login_shell'] = login_shell |
||||
operations.append('replace_login_shell') |
||||
click.echo('Login shell will be set to: ' + login_shell) |
||||
if forwarding_addresses is not None: |
||||
forwarding_addresses = forwarding_addresses.split(',') |
||||
body['forwarding_addresses'] = forwarding_addresses |
||||
operations.append('replace_forwarding_addresses') |
||||
prefix = '~/.forward will be set to: ' |
||||
click.echo(prefix + forwarding_addresses[0]) |
||||
for address in forwarding_addresses[1:]: |
||||
click.echo((' ' * len(prefix)) + address) |
||||
|
||||
click.confirm('Do you want to continue?', abort=True) |
||||
|
||||
resp = http_patch('/api/members/' + username, json=body) |
||||
handle_stream_response(resp, operations) |
||||
|
||||
|
||||
@members.command(short_help="Renew a member or club rep's membership") |
||||
@click.argument('username') |
||||
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100), |
||||
help='Number of terms to add', prompt='Number of terms') |
||||
@click.option('--clubrep', is_flag=True, default=False, |
||||
help='Add non-member terms instead of member terms') |
||||
def renew(username, num_terms, clubrep): |
||||
resp = http_get('/api/members/' + username) |
||||
result = handle_sync_response(resp) |
||||
max_term = None |
||||
if clubrep and 'non_member_terms' in result: |
||||
max_term = get_max_term(result['non_member_terms']) |
||||
elif not clubrep and 'terms' in result: |
||||
max_term = get_max_term(result['terms']) |
||||
if max_term is not None: |
||||
max_term = get_max_term([max_term, get_current_term()]) |
||||
else: |
||||
max_term = get_current_term() |
||||
|
||||
terms = [add_term(max_term)] |
||||
for _ in range(1, num_terms): |
||||
term = add_term(terms[-1]) |
||||
terms.append(term) |
||||
|
||||
if clubrep: |
||||
body = {'non_member_terms': terms} |
||||
click.echo('The following non-member terms will be added: ' + ','.join(terms)) |
||||
else: |
||||
body = {'terms': terms} |
||||
click.echo('The following member terms will be added: ' + ','.join(terms)) |
||||
|
||||
click.confirm('Do you want to continue?', abort=True) |
||||
|
||||
resp = http_post(f'/api/members/{username}/renew', json=body) |
||||
handle_sync_response(resp) |
||||
click.echo('Done.') |
||||
|
||||
|
||||
@members.command(short_help="Reset a user's password") |
||||
@click.argument('username') |
||||
def pwreset(username): |
||||
click.confirm(f"Are you sure you want to reset {username}'s password?", abort=True) |
||||
resp = http_post(f'/api/members/{username}/pwreset') |
||||
result = handle_sync_response(resp) |
||||
click.echo('New password: ' + result['password']) |
||||
|
||||
|
||||
@members.command(short_help="Delete a user") |
||||
@click.argument('username') |
||||
def delete(username): |
||||
# a hack to determine if we're in the dev environment |
||||
if not socket.getfqdn().endswith('.csclub.internal'): |
||||
click.echo('This command may only be called during development.') |
||||
sys.exit(1) |
||||
click.confirm(f"Are you sure you want to delete {username}?", abort=True) |
||||
resp = http_delete(f'/api/members/{username}') |
||||
handle_stream_response(resp, DeleteMemberTransaction.operations) |
@ -0,0 +1,19 @@ |
||||
# These descriptions are printed to the console while a transaction |
||||
# is performed, in real time. |
||||
descriptions = { |
||||
'add_user_to_ldap': 'Add user to LDAP', |
||||
'add_group_to_ldap': 'Add group to LDAP', |
||||
'add_user_to_kerberos': 'Add user to Kerberos', |
||||
'create_home_dir': 'Create home directory', |
||||
'set_forwarding_addresses': 'Set forwarding addresses', |
||||
'send_welcome_message': 'Send welcome message', |
||||
'subscribe_to_mailing_list': 'Subscribe to mailing list', |
||||
'announce_new_user': 'Announce new user to mailing list', |
||||
'replace_login_shell': 'Replace login shell', |
||||
'replace_forwarding_addresses': 'Replace forwarding addresses', |
||||
'remove_user_from_ldap': 'Remove user from LDAP', |
||||
'remove_group_from_ldap': 'Remove group from LDAP', |
||||
'remove_user_from_kerberos': 'Remove user from Kerberos', |
||||
'delete_home_dir': 'Delete home directory', |
||||
'unsubscribe_from_mailing_list': 'Unsubscribe from mailing list', |
||||
} |
@ -0,0 +1,149 @@ |
||||
import json |
||||
import sys |
||||
from typing import List, Tuple, Dict |
||||
|
||||
import click |
||||
import requests |
||||
from zope import component |
||||
|
||||
from .operation_strings import descriptions as op_desc |
||||
from ceo_common.interfaces import IHTTPClient, IConfig |
||||
|
||||
|
||||
def http_request(method: str, path: str, **kwargs) -> requests.Response: |
||||
client = component.getUtility(IHTTPClient) |
||||
cfg = component.getUtility(IConfig) |
||||
if path.startswith('/api/db'): |
||||
host = cfg.get('ceod_db_host') |
||||
need_cred = False |
||||
else: |
||||
host = cfg.get('ceod_admin_host') |
||||
# The forwarded TGT is only needed for endpoints which write to LDAP |
||||
need_cred = method != 'GET' |
||||
return client.request( |
||||
host, path, method, principal=None, need_cred=need_cred, |
||||
stream=True, **kwargs) |
||||
|
||||
|
||||
def http_get(path: str, **kwargs) -> requests.Response: |
||||
return http_request('GET', path, **kwargs) |
||||
|
||||
|
||||
def http_post(path: str, **kwargs) -> requests.Response: |
||||
return http_request('POST', path, **kwargs) |
||||
|
||||
|
||||
def http_patch(path: str, **kwargs) -> requests.Response: |
||||
return http_request('PATCH', path, **kwargs) |
||||
|
||||
|
||||
def http_delete(path: str, **kwargs) -> requests.Response: |
||||
return http_request('DELETE', path, **kwargs) |
||||
|
||||
|
||||
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]: |
||||
""" |
||||
Print output to the console while operations are being streamed |
||||
from the server over HTTP. |
||||
Returns the parsed JSON data streamed from the server. |
||||
""" |
||||
if resp.status_code != 200: |
||||
click.echo('An error occurred:') |
||||
click.echo(resp.text) |
||||
sys.exit(1) |
||||
click.echo(op_desc[operations[0]] + '... ', nl=False) |
||||
idx = 0 |
||||
data = [] |
||||
for line in resp.iter_lines(decode_unicode=True, chunk_size=8): |
||||
d = json.loads(line) |
||||
data.append(d) |
||||
if d['status'] == 'aborted': |
||||
click.echo(click.style('ABORTED', fg='red')) |
||||
click.echo('The transaction was rolled back.') |
||||
click.echo('The error was: ' + d['error']) |
||||
click.echo('Please check the ceod logs.') |
||||
sys.exit(1) |
||||
elif d['status'] == 'completed': |
||||
click.echo('Transaction successfully completed.') |
||||
return data |
||||
|
||||
operation = d['operation'] |
||||
oper_failed = False |
||||
err_msg = None |
||||
prefix = 'failed_to_' |
||||
if operation.startswith(prefix): |
||||
operation = operation[len(prefix):] |
||||
oper_failed = True |
||||
# sometimes the operation looks like |
||||
# "failed_to_do_something: error message" |
||||
if ':' in operation: |
||||
operation, err_msg = operation.split(': ', 1) |
||||
|
||||
while idx < len(operations) and operations[idx] != operation: |
||||
click.echo('Skipped') |
||||
idx += 1 |
||||
if idx == len(operations): |
||||
break |
||||
click.echo(op_desc[operations[idx]] + '... ', nl=False) |
||||
if idx == len(operations): |
||||
click.echo('Unrecognized operation: ' + operation) |
||||
continue |
||||
if oper_failed: |
||||
click.echo(click.style('Failed', fg='red')) |
||||
if err_msg is not None: |
||||
click.echo(' Error message: ' + err_msg) |
||||
else: |
||||
click.echo(click.style('Done', fg='green')) |
||||
idx += 1 |
||||
if idx < len(operations): |
||||
click.echo(op_desc[operations[idx]] + '... ', nl=False) |
||||
|
||||
raise Exception('server response ended abruptly') |
||||
|
||||
|
||||
def handle_sync_response(resp: requests.Response): |
||||
""" |
||||
Exit the program if the request was not successful. |
||||
Returns the parsed JSON response. |
||||
""" |
||||
if resp.status_code // 100 != 2: |
||||
click.echo('An error occurred:') |
||||
click.echo(resp.text) |
||||
sys.exit(1) |
||||
return resp.json() |
||||
|
||||
|
||||
def print_colon_kv(pairs: List[Tuple[str, str]]): |
||||
""" |
||||
Pretty-print a list of key-value pairs such that the key and value |
||||
columns align. |
||||
Example: |
||||
key1: value1 |
||||
key1000: value2 |
||||
""" |
||||
maxlen = max(len(key) for key, val in pairs) |
||||
for key, val in pairs: |
||||
click.echo(key + ': ', nl=False) |
||||
extra_space = ' ' * (maxlen - len(key)) |
||||
click.echo(extra_space, nl=False) |
||||
click.echo(val) |
||||
|
||||
|
||||
def get_failed_operations(data: List[Dict]) -> List[str]: |
||||
""" |
||||
Get a list of the failed operations using the JSON objects |
||||
streamed from the server. |
||||
""" |
||||
prefix = 'failed_to_' |
||||
failed = [] |
||||
for d in data: |
||||
if 'operation' not in d: |
||||
continue |
||||
operation = d['operation'] |
||||
if not operation.startswith(prefix): |
||||
continue |
||||
operation = operation[len(prefix):] |
||||
if ':' in operation: |
||||
operation = operation[:operation.index(':')] |
||||
failed.append(operation) |
||||
return failed |
@ -1,14 +1,27 @@ |
||||
from typing import Union |
||||
|
||||
from zope.interface import Interface |
||||
|
||||
|
||||
class IHTTPClient(Interface): |
||||
"""A helper class for HTTP requests to ceod.""" |
||||
|
||||
def get(host: str, api_path: str, **kwargs): |
||||
def request(host: str, api_path: str, method: str, principal: str, |
||||
need_cred: bool, **kwargs): |
||||
"""Make an HTTP request.""" |
||||
|
||||
def get(host: str, api_path: str, principal: Union[str, None] = None, |
||||
need_cred: bool = True, **kwargs): |
||||
"""Make a GET request.""" |
||||
|
||||
def post(host: str, api_path: str, **kwargs): |
||||
def post(host: str, api_path: str, principal: Union[str, None] = None, |
||||
need_cred: bool = True, **kwargs): |
||||
"""Make a POST request.""" |
||||
|
||||
def delete(host: str, api_path: str, **kwargs): |
||||
def patch(host: str, api_path: str, principal: Union[str, None] = None, |
||||
need_cred: bool = True, **kwargs): |
||||
"""Make a PATCH request.""" |
||||
|
||||
def delete(host: str, api_path: str, principal: Union[str, None] = None, |
||||
need_cred: bool = True, **kwargs): |
||||
"""Make a DELETE request.""" |
||||
|
@ -0,0 +1,40 @@ |
||||
import datetime |
||||
from typing import List |
||||
|
||||
|
||||
def get_current_term() -> str: |
||||
""" |
||||
Get the current term as formatted in the CSC LDAP (e.g. 's2021'). |
||||
""" |
||||
dt = datetime.datetime.now() |
||||
c = 'w' |
||||
if 5 <= dt.month <= 8: |
||||
c = 's' |
||||
elif 9 <= dt.month: |
||||
c = 'f' |
||||
return c + str(dt.year) |
||||
|
||||
|
||||
def add_term(term: str) -> str: |
||||
""" |
||||
Add one term to the given term and return the string. |
||||
Example: add_term('s2021') -> 'f2021' |
||||
""" |
||||
c = term[0] |
||||
s_year = term[1:] |
||||
if c == 'w': |
||||
return 's' + s_year |
||||
elif c == 's': |
||||
return 'f' + s_year |
||||
year = int(s_year) |
||||
return 'w' + str(year + 1) |
||||
|
||||
|
||||
def get_max_term(terms: List[str]) -> str: |
||||
"""Get the maximum (latest) term.""" |
||||
max_year = max(term[1:] for term in terms) |
||||
if 'f' + max_year in terms: |
||||
return 'f' + max_year |
||||
elif 's' + max_year in terms: |
||||
return 's' + max_year |
||||
return 'w' + max_year |
@ -0,0 +1,9 @@ |
||||
[DEFAULT] |
||||
base_domain = csclub.internal |
||||
uw_domain = uwaterloo.internal |
||||
|
||||
[ceod] |
||||
# this is the host with the ceod/admin Kerberos key |
||||
admin_host = phosphoric-acid |
||||
use_https = false |
||||
port = 9987 |
Loading…
Reference in new issue