from flask import Blueprint, g, request from flask.json import jsonify from zope import component from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \ user_is_in_group, requires_authentication_no_realm, \ create_streaming_response, development_only, is_truthy from ceo_common.errors import BadRequest, UserAlreadySubscribedError, UserNotSubscribedError from ceo_common.interfaces import ILDAPService, IConfig, IMailService from ceo_common.logger_factory import logger_factory from ceod.transactions.members import ( AddMemberTransaction, ModifyMemberTransaction, DeleteMemberTransaction, ) import ceod.utils as utils bp = Blueprint('members', __name__) logger = logger_factory(__name__) @bp.route('/', methods=['POST'], strict_slashes=False) @authz_restrict_to_staff def create_user(): body = request.get_json(force=True) terms = body.get('terms') non_member_terms = body.get('non_member_terms') if (terms and non_member_terms) or not (terms or non_member_terms): raise BadRequest('Must specify either terms or non-member terms') for attr in ['uid', 'cn', 'given_name', 'sn']: if not body.get(attr): raise BadRequest(f"Attribute '{attr}' is missing or empty") # We need to use the admin creds here because office members may not # directly create new LDAP records. g.need_admin_creds = True txn = AddMemberTransaction( uid=body['uid'], cn=body['cn'], given_name=body['given_name'], sn=body['sn'], program=body.get('program'), terms=terms, non_member_terms=non_member_terms, forwarding_addresses=body.get('forwarding_addresses'), ) return create_streaming_response(txn) @bp.route('/') @requires_authentication_no_realm def get_user(auth_user: str, username: str): get_forwarding_addresses = False if user_is_in_group(auth_user, 'syscom'): # Only syscom members may see the user's forwarding addresses, # since this requires reading a file in the user's home directory. # To avoid situations where an unprivileged user symlinks their # ~/.forward file to /etc/shadow or something, we don't allow # non-syscom members to use this option either. get_forwarding_addresses = True ldap_srv = component.getUtility(ILDAPService) user = ldap_srv.get_user(username) return user.to_dict(get_forwarding_addresses) @bp.route('/', methods=['PATCH']) @requires_authentication_no_realm def patch_user(auth_user: str, username: str): if not (auth_user == username or user_is_in_group(auth_user, 'syscom')): return { 'error': "You are not authorized to modify other users' attributes" }, 403 body = request.get_json(force=True) txn = ModifyMemberTransaction( username, login_shell=body.get('login_shell'), forwarding_addresses=body.get('forwarding_addresses'), ) return create_streaming_response(txn) @bp.route('//renew', methods=['POST']) @authz_restrict_to_staff def renew_user(username: str): body = request.get_json(force=True) terms = body.get('terms') non_member_terms = body.get('non_member_terms') if (terms and non_member_terms) or not (terms or non_member_terms): raise BadRequest('Must specify either terms or non-member terms') # We need to use the admin creds here because office members should # not be able to directly modify the shadowExpire field; this could # prevent syscom members from logging into the machines. g.need_admin_creds = True ldap_srv = component.getUtility(ILDAPService) cfg = component.getUtility(IConfig) user = ldap_srv.get_user(username) member_list = cfg.get('mailman3_new_member_list') def unexpire(user): if user.shadowExpire: user.set_expired(False) try: user.subscribe_to_mailing_list(member_list) except UserAlreadySubscribedError: pass if body.get('terms'): user.add_terms(body['terms']) unexpire(user) return {'terms_added': body['terms']} elif body.get('non_member_terms'): user.add_non_member_terms(body['non_member_terms']) unexpire(user) return {'non_member_terms_added': body['non_member_terms']} else: raise BadRequest('Must specify either terms or non-member terms') @bp.route('//pwreset', methods=['POST']) @authz_restrict_to_syscom def reset_user_password(username: str): user = component.getUtility(ILDAPService).get_user(username) password = utils.gen_password() user.change_password(password) return {'password': password} @bp.route('/', methods=['DELETE']) @authz_restrict_to_syscom @development_only def delete_user(username: str): txn = DeleteMemberTransaction(username) return create_streaming_response(txn) @bp.route('/expire', methods=['POST']) @authz_restrict_to_syscom def expire_users(): dry_run = is_truthy(request.args.get('dry_run', 'false')) ldap_srv = component.getUtility(ILDAPService) cfg = component.getUtility(IConfig) members = ldap_srv.get_nonflagged_expired_users() member_list = cfg.get('mailman3_new_member_list') if not dry_run: for member in members: member.set_expired(True) try: member.unsubscribe_from_mailing_list(member_list) except UserNotSubscribedError: pass return jsonify([member.uid for member in members]) @bp.route('/remindexpire', methods=['POST']) @authz_restrict_to_syscom def remind_users_of_expiration(): dry_run = is_truthy(request.args.get('dry_run', 'false')) ldap_srv = component.getUtility(ILDAPService) members = ldap_srv.get_expiring_users() if not dry_run: mail_srv = component.getUtility(IMailService) for member in members: logger.info(f'Sending renewal reminder to {member.uid}') mail_srv.send_membership_renewal_reminder(member) return jsonify([member.uid for member in members])