def post(self): """Associate existing users or unanswered invites with projects or create invites""" args = flask.request.args json_info = flask.request.json # Verify valid role (should also catch None) role = json_info.get("role") if not dds_web.utils.valid_user_role(specified_role=role): raise ddserr.DDSArgumentError(message="Invalid user role.") # Unit only changable for Super Admin invites unit = json_info.get( "unit") if auth.current_user().role == "Super Admin" else None # A project may or may not be specified project = args.get("project") if args else None if project: project = project_schemas.ProjectRequiredSchema().load( {"project": project}) # Verify email email = json_info.get("email") if not email: raise ddserr.DDSArgumentError( message="Email address required to add or invite.") # Notify the users about project additions? Invites are still being sent out. send_email = json_info.get("send_email", True) # Check if email is registered to a user try: existing_user = user_schemas.UserSchema().load({"email": email}) unanswered_invite = user_schemas.UnansweredInvite().load( {"email": email}) except sqlalchemy.exc.OperationalError as err: raise ddserr.DatabaseError( message=str(err), alt_message="Unexpected database error.") if existing_user or unanswered_invite: if not project: raise ddserr.DDSArgumentError(message=( "This user was already added to the system. " "Specify the project you wish to give access to.")) add_user_result = self.add_to_project( whom=existing_user or unanswered_invite, project=project, role=role, send_email=send_email, ) return add_user_result, add_user_result["status"] else: # Send invite if the user doesn't exist invite_user_result = self.invite_user(email=email, new_user_role=role, project=project, unit=unit) return invite_user_result, invite_user_result["status"]
def get(self): """List unit users within the unit the current user is connected to, or the one defined by a superadmin.""" unit_users = {} if auth.current_user().role == "Super Admin": json_input = flask.request.json if not json_input: raise ddserr.DDSArgumentError(message="Unit public id missing.") unit = json_input.get("unit") if not unit: raise ddserr.DDSArgumentError(message="Unit public id missing.") unit_row = models.Unit.query.filter_by(public_id=unit).one_or_none() if not unit_row: raise ddserr.DDSArgumentError( message=f"There is no unit with the public id '{unit}'." ) else: unit_row = auth.current_user().unit keys = ["Name", "Username", "Email", "Role", "Active"] unit_users = [ { "Name": user.name, "Username": user.username, "Email": user.primary_email, "Role": user.role, "Active": user.is_active, } for user in unit_row.users ] return {"users": unit_users, "keys": keys, "unit": unit_row.name}
def post(self): """Remove a user from a project""" project = project_schemas.ProjectRequiredSchema().load(flask.request.args) json_input = flask.request.json if not (user_email := json_input.get("email")): raise ddserr.DDSArgumentError(message="User email missing.")
def generate_required_fields(self, data, **kwargs): """Generate all required fields for creating a project.""" if not data: raise ddserr.DDSArgumentError( "No project information found when attempting to create project." ) data["date_created"] = dds_web.utils.current_time() return data
def post(self): """Add a MOTD.""" curr_date = utils.current_time() json_input = flask.request.json motd = json_input.get("message") if not motd: raise ddserr.DDSArgumentError(message="No MOTD specified.") flask.current_app.logger.debug(motd) new_motd = models.MOTD(message=motd, date_created=curr_date) db.session.add(new_motd) db.session.commit()
def return_items(self, data, **kwargs): """Return project contents as serialized.""" # Fields requested_items = data.get("requested_items") url = data.get("url") get_all = data.get("get_all") # Check if project has contents project_row = verify_project_exists(spec_proj=data.get("project")) if not project_row.files: raise ddserr.EmptyProjectException(project=project_row.public_id) # Check if specific files have been requested or if requested all contents files, folder_contents, not_found = (None, None, None) if requested_items: files, folder_contents, not_found = self.find_contents( project=project_row, contents=requested_items) elif get_all: files = project_row.files else: raise ddserr.DDSArgumentError(message="No items were requested.") # Items to return found_files = {} found_folder_contents = {} not_found = {} # Use file schema to get file info automatically fileschema = sqlalchemyautoschemas.FileSchema( many=False, only=( "name_in_bucket", "subpath", "size_original", "size_stored", "salt", "public_key", "checksum", "compressed", ), ) # Connect to s3 with api_s3_connector.ApiS3Connector(project=project_row) as s3: # Get the info and signed urls for all files try: found_files.update({ x.name: { **fileschema.dump(x), "url": s3.generate_get_url( key=x.name_in_bucket) if url else None, } for x in files }) if folder_contents: # Get all info and signed urls for all folder contents found in the bucket for x, y in folder_contents.items(): if x not in found_folder_contents: found_folder_contents[x] = {} found_folder_contents[x].update({ z.name: { **fileschema.dump(z), "url": s3.generate_get_url( key=z.name_in_bucket) if url else None, } for z in y }) except botocore.client.ClientError as clierr: raise ddserr.S3ConnectionError( message=str(clierr), alt_message="Could not generate presigned urls.") return found_files, found_folder_contents, not_found
def post(self): # Verify that user specified json_input = flask.request.json if "email" not in json_input: raise ddserr.DDSArgumentError(message="User email missing.") try: user = user_schemas.UserSchema().load( {"email": json_input.pop("email")}) except sqlalchemy.exc.OperationalError as err: raise ddserr.DatabaseError( message=str(err), alt_message="Unexpected database error.") if not user: raise ddserr.NoSuchUserError() # Verify that the action is specified -- reactivate or deactivate action = json_input.get("action") if not action: raise ddserr.DDSArgumentError( message= "Please provide an action 'deactivate' or 'reactivate' for this request." ) user_email_str = user.primary_email current_user = auth.current_user() if current_user.role == "Unit Admin": # Unit Admin can only activate/deactivate Unit Admins and personnel if user.role not in ["Unit Admin", "Unit Personnel"]: raise ddserr.AccessDeniedError( message=("You can only activate/deactivate users with " "the role Unit Admin or Unit Personnel.")) if current_user.unit != user.unit: raise ddserr.AccessDeniedError(message=( "As a Unit Admin, you can only activate/deactivate other Unit Admins or " "Unit Personnel within your specific unit.")) if current_user == user: raise ddserr.AccessDeniedError( message=f"You cannot {action} your own account!") if (action == "reactivate" and user.is_active) or (action == "deactivate" and not user.is_active): raise ddserr.DDSArgumentError( message=f"User is already {action}d!") # TODO: Check if user has lost access to any projects and if so, grant access again. if action == "reactivate": user.active = True # TODO: Super admins (current_user) don't have access to projects currently, how handle this? list_of_projects = None if user.role in ["Project Owner", "Researcher"]: list_of_projects = [ x.project for x in user.project_associations ] elif user.role in ["Unit Personnel", "Unit Admin"]: list_of_projects = user.unit.projects from dds_web.api.project import ProjectAccess # Needs to be here because of circ.import ProjectAccess.give_project_access(project_list=list_of_projects, current_user=current_user, user=user) else: user.active = False try: db.session.commit() except (sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.OperationalError) as err: db.session.rollback() raise ddserr.DatabaseError( message=str(err), alt_message=f"Unexpected database error" + (": Database malfunction." if isinstance( err, sqlalchemy.exc.OperationalError) else "."), ) from err msg = ( f"The user account {user.username} ({user_email_str}, {user.role}) " f" has been {action}d successfully been by {current_user.name} ({current_user.role})." ) flask.current_app.logger.info(msg) with structlog.threadlocal.bound_threadlocal( who={ "user": user.username, "role": user.role }, by_whom={ "user": current_user.username, "role": current_user.role }, ): action_logger.info(self.__class__) return { "message": (f"You successfully {action}d the account {user.username} " f"({user_email_str}, {user.role})!") }
def compose_and_send_email_to_user(userobj, mail_type, link=None, project=None): """Compose and send email""" if hasattr(userobj, "emails"): recipients = [x.email for x in userobj.emails] else: # userobj likely an invite recipients = [userobj.email] unit_name = None unit_email = None project_id = None deadline = None if auth.current_user().role in ["Unit Admin", "Unit Personnel"]: unit = auth.current_user().unit unit_name = unit.external_display_name unit_email = unit.contact_email sender_name = auth.current_user().name subject_subject = unit_name else: sender_name = auth.current_user().name subject_subject = sender_name # Fill in email subject with sentence subject if mail_type == "invite": subject = f"{subject_subject} invites you to the SciLifeLab Data Delivery System" elif mail_type == "project_release": subject = f"Project made available by {subject_subject} in the SciLifeLab Data Delivery System" project_id = project.public_id deadline = project.current_deadline.astimezone( datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z") else: raise ddserr.DDSArgumentError(message="Invalid mail type!") msg = flask_mail.Message( subject, recipients=recipients, ) # Need to attach the image to be able to use it msg.attach( "scilifelab_logo.png", "image/png", open( os.path.join(flask.current_app.static_folder, "img/scilifelab_logo.png"), "rb").read(), "inline", headers=[ ["Content-ID", "<Logo>"], ], ) msg.body = flask.render_template( f"mail/{mail_type}.txt", link=link, sender_name=sender_name, unit_name=unit_name, unit_email=unit_email, project_id=project_id, deadline=deadline, ) msg.html = flask.render_template( f"mail/{mail_type}.html", link=link, sender_name=sender_name, unit_name=unit_name, unit_email=unit_email, project_id=project_id, deadline=deadline, ) AddUser.send_email_with_retry(msg)
def invite_user(email, new_user_role, project=None, unit=None): """Invite a new user""" current_user_role = get_user_roles_common(user=auth.current_user()) if not project: if current_user_role == "Project Owner": return { "status": ddserr.InviteError.code.value, "message": "Project ID required to invite users to projects.", } if new_user_role == "Project Owner": return { "status": ddserr.InviteError.code.value, "message": "Project ID required to invite a 'Project Owner'.", } # Verify role or current and new user if current_user_role == "Super Admin" and project: return { "status": ddserr.InviteError.code.value, "message": ("Super Admins do not have project data access and can therefore " "not invite users to specific projects."), } elif current_user_role == "Unit Admin" and new_user_role == "Super Admin": return { "status": ddserr.AccessDeniedError.code.value, "message": ddserr.AccessDeniedError.description, } elif current_user_role == "Unit Personnel" and new_user_role in [ "Super Admin", "Unit Admin", ]: return { "status": ddserr.AccessDeniedError.code.value, "message": ddserr.AccessDeniedError.description, } elif current_user_role == "Project Owner" and new_user_role in [ "Super Admin", "Unit Admin", "Unit Personnel", ]: return { "status": ddserr.AccessDeniedError.code.value, "message": ddserr.AccessDeniedError.description, } elif current_user_role == "Researcher": return { "status": ddserr.AccessDeniedError.code.value, "message": ddserr.AccessDeniedError.description, } # Create invite row new_invite = models.Invite( email=email, role=("Researcher" if new_user_role == "Project Owner" else new_user_role), ) # Create URL safe token for invitation link token = encrypted_jwt_token( username="", sensitive_content=generate_invite_key_pair( invite=new_invite).hex(), expires_in=datetime.timedelta( hours=flask.current_app.config["INVITATION_EXPIRES_IN_HOURS"]), additional_claims={"inv": new_invite.email}, ) # Create link for invitation email link = flask.url_for("auth_blueprint.confirm_invite", token=token, _external=True) # Quick search gave this as the URL length limit. if len(link) >= 2048: flask.current_app.logger.error( "Invitation link was not possible to create due to length.") return { "message": "Invite failed due to server error", "status": http.HTTPStatus.INTERNAL_SERVER_ERROR, } projects_not_shared = {} goahead = False # Append invite to unit if applicable if new_invite.role in ["Unit Admin", "Unit Personnel"]: # TODO Change / move this later. This is just so that we can add an initial Unit Admin. if auth.current_user().role == "Super Admin": if unit: unit_row = models.Unit.query.filter_by( public_id=unit).one_or_none() if not unit_row: raise ddserr.DDSArgumentError( message="Invalid unit publid id.") unit_row.invites.append(new_invite) goahead = True else: raise ddserr.DDSArgumentError( message= "You need to specify a unit to invite a Unit Personnel or Unit Admin." ) if "Unit" in auth.current_user().role: # Give new unit user access to all projects of the unit auth.current_user().unit.invites.append(new_invite) if auth.current_user().unit.projects: for unit_project in auth.current_user().unit.projects: if unit_project.is_active: try: share_project_private_key( from_user=auth.current_user(), to_another=new_invite, from_user_token=dds_web.security.auth. obtain_current_encrypted_token(), project=unit_project, ) except ddserr.KeyNotFoundError as keyerr: projects_not_shared[ unit_project. public_id] = "You do not have access to the project(s)" else: goahead = True else: goahead = True if not project: # specified project is disregarded for unituser invites msg = f"{str(new_invite)} was successful." else: msg = f"{str(new_invite)} was successful, but specification for {str(project)} dropped. Unit Users have automatic access to projects of their unit." else: db.session.add(new_invite) if project: try: share_project_private_key( from_user=auth.current_user(), to_another=new_invite, project=project, from_user_token=dds_web.security.auth. obtain_current_encrypted_token(), is_project_owner=new_user_role == "Project Owner", ) except ddserr.KeyNotFoundError as keyerr: projects_not_shared[ project. public_id] = "You do not have access to the specified project." else: goahead = True else: goahead = True # Compose and send email status_code = http.HTTPStatus.OK if goahead: try: db.session.commit() except (sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.OperationalError) as sqlerr: db.session.rollback() raise ddserr.DatabaseError( message=str(sqlerr), alt_message=f"Invitation failed" + (": Database malfunction." if isinstance( sqlerr, sqlalchemy.exc.OperationalError) else "."), ) from sqlerr AddUser.compose_and_send_email_to_user(userobj=new_invite, mail_type="invite", link=link) msg = f"{str(new_invite)} was successful." else: msg = (f"The user could not be added to the project(s)." if projects_not_shared else "Unknown error!") + " The invite did not succeed." status_code = ddserr.InviteError.code.value return { "email": new_invite.email, "message": msg, "status": status_code, "errors": projects_not_shared, }