def info(): address = query_param("address") id = query_param("id", required=False) try: ip_network = ipaddress.ip_network(address, False) except ValueError: return { "error": True, "network_value": address, "syntax": True, "id": id }, 200 _is4 = ip_network.version == 4 prefix = ip_network.prefixlen if (_is4 and prefix < max_allowed_ipv4_sub_mask) or (not _is4 and prefix < max_allowed_ipv6_prefix): return { "error": True, "version": ip_network.version, "max": max_allowed_ipv4_sub_mask if _is4 else max_allowed_ipv6_prefix, "network_value": address, "prefix": prefix, "id": id }, 200 return { "version": ip_network.version, "num_addresses": ip_network.num_addresses, "network_value": str(ip_network), "lower": str(ip_network[0]), "higher": str(ip_network[-1]), "id": id }, 200
def attributes(): uid = query_param("uid") service_entity_id = query_param("service_entity_id") def not_authorized_func(_, status): if status == USER_UNKNOWN: return {"error": f"user {uid} is unknown"}, 404 elif status == USER_IS_SUSPENDED: return {"error": f"user {uid} is suspended"}, 404 elif status == SERVICE_UNKNOWN or status == SERVICE_NOT_CONNECTED or status == COLLABORATION_NOT_ACTIVE: return {}, 200 elif status == AUP_NOT_AGREED: return {"error": f"user {uid} has not agreed to the aup of {service_entity_id}"}, 403 def authorized_func(user, memberships): # gather regular user attributes result = {} for k, v in custom_saml_mapping["attribute_saml_mapping"].items(): val = getattr(user, k) if val: result[v] = val.split(",") if k in custom_saml_mapping["multi_value_attributes"] else [val] result["sshKey"] = [ssh_key.ssh_value for ssh_key in user.ssh_keys] membership_attribute = custom_saml_mapping['custom_attribute_saml_mapping']['memberships'] result[membership_attribute] = memberships result = {k: list(set(v)) for k, v in result.items()} return result, 200 return _do_attributes(uid, service_entity_id, not_authorized_func, authorized_func)
def short_name_exists(): short_name = query_param("short_name") existing_organisation = query_param("existing_organisation", required=False, default="") org = Organisation.query.options(load_only("id")) \ .filter(func.lower(Organisation.short_name) == func.lower(short_name)) \ .filter(func.lower(Organisation.short_name) != func.lower(existing_organisation)) \ .first() return org is not None, 200
def short_name_exists(): short_name = query_param("short_name") collaboration_id = query_param("collaboration_id") existing_group = query_param("existing_group", required=False, default="") group = Group.query.options(load_only("id")) \ .filter(func.lower(Group.short_name) == func.lower(short_name)) \ .filter(func.lower(Group.short_name) != func.lower(existing_group)) \ .filter(Group.collaboration_id == collaboration_id) \ .first() return group is not None, 200
def service_group_short_name_exists(): short_name = query_param("short_name") service_id = query_param("service_id") existing_service_group = query_param("existing_service_group", required=False, default="") service_group = ServiceGroup.query.options(load_only("id")) \ .filter(func.lower(ServiceGroup.short_name) == func.lower(short_name)) \ .filter(func.lower(ServiceGroup.short_name) != func.lower(existing_service_group)) \ .filter(ServiceGroup.service_id == service_id) \ .first() return service_group is not None, 200
def abbreviation_exists(): abbreviation = query_param("abbreviation") existing_service = query_param("existing_service", required=False, default="") service = Service.query.options(load_only("id")) \ .filter(func.lower(Service.abbreviation) == func.lower(abbreviation)) \ .filter(func.lower(Service.abbreviation) != func.lower(existing_service)) \ .first() if service: confirm_service_admin(service.id) return service is not None, 200
def entity_id_exists(): entity_id = query_param("entity_id") existing_service = query_param("existing_service", required=False, default="") service = Service.query.options(load_only("id")) \ .filter(func.lower(Service.entity_id) == func.lower(entity_id)) \ .filter(func.lower(Service.entity_id) != func.lower(existing_service)) \ .first() if service: confirm_service_admin(service.id) return service is not None, 200
def activity(): limit = int(query_param("limit", False, 0)) tables = list(filter(lambda s: s.strip(), query_param("tables", False, "").split(","))) query = AuditLog.query.order_by(desc(AuditLog.created_at)) if tables: query = query.filter(AuditLog.target_type.in_(tables)) if limit: query = query.limit(limit) audit_logs = query.all() return _add_references(audit_logs), 200
def schac_home_exists(): schac_home = query_param("schac_home") if not schac_home: return False, 200 existing_organisation_id = query_param("existing_organisation_id", required=False) query = SchacHomeOrganisation.query \ .filter(func.lower(SchacHomeOrganisation.name) == func.lower(schac_home)) if existing_organisation_id: query = query \ .filter(SchacHomeOrganisation.organisation_id != int(existing_organisation_id)) res = query.first() return res.name if res else False, 200
def sfo(): logger = ctx_logger("oidc") oidc_config = current_app.app_config.oidc encoded_access_token = query_param("access_token") access_token = decode_jwt_token(encoded_access_token) logger.debug(f"MFA endpoint with access_token {access_token}") uid = access_token["sub"] user = User.query.filter(User.uid == uid).first() if not user: error_msg = f"Unknown user with sub {uid}" logger.error(error_msg) return redirect(f"{oidc_config.sfo_eduteams_redirect_uri}?error={quote(error_msg)}") if not oidc_config.second_factor_authentication_required: id_token = _construct_jwt(user, access_token.get("nonce", str(uuid4())), oidc_config) return redirect(f"{oidc_config.sfo_eduteams_redirect_uri}?id_token={id_token}") # need to remember this if the user response comes back session["in_proxy_flow"] = True store_user_in_session(user, False, user.has_agreed_with_aup()) return redirect(f"{current_app.app_config.base_url}/2fa")
def other(): confirm_allow_impersonation() uid = query_param("uid") user = _user_query().filter(User.uid == uid).one() # avoid 2fa registration / validation return _user_json_response(user, True)
def service_invitations_by_hash(): hash_value = query_param("hash") invitation_query = _service_invitation_query() service_invitation = invitation_query \ .options(selectinload(ServiceInvitation.service).selectinload(Service.service_memberships) .selectinload(ServiceMembership.user)) \ .filter(ServiceInvitation.hash == hash_value) \ .one() return service_invitation, 200
def identity_provider_display_name(): user = User.query.filter(User.id == current_user_id()).one() schac_home_organisation = user.schac_home_organisation if not schac_home_organisation: return None, 200 lang = query_param("lang", required=False, default="en") idp_name = idp_display_name(schac_home_organisation, lang) return {"display_name": idp_name}, 200
def invitations_by_hash(): hash_value = query_param("hash") invitation_query = _invitation_query() invitation = invitation_query \ .options(selectinload(Invitation.groups)) \ .options(selectinload(Invitation.collaboration).selectinload(Collaboration.collaboration_memberships) .selectinload(CollaborationMembership.user)) \ .options(selectinload(Invitation.collaboration).selectinload(Collaboration.groups)) \ .options(selectinload(Invitation.collaboration).selectinload(Collaboration.services)) \ .options(selectinload(Invitation.collaboration).selectinload(Collaboration.organisation) .selectinload(Organisation.services)) \ .filter(Invitation.hash == hash_value) \ .one() if not query_param("expand", required=False): return invitation, 200 invitation_json = jsonify(invitation).json service_emails = invitation.collaboration.service_emails() return {"invitation": invitation_json, "service_emails": service_emails}, 200
def _user_activity(user_id): limit = int(query_param("limit", False, 1000)) audit_logs = AuditLog.query \ .filter(( (AuditLog.target_id == user_id) & (AuditLog.target_type == User.__tablename__)) | ( AuditLog.subject_id == user_id)) \ .order_by(desc(AuditLog.created_at)) \ .limit(limit) \ .all() return _add_references(audit_logs), 200
def organisation_invitations_by_hash(): hash_value = query_param("hash") invitation_query = _organisation_invitation_query() organisation_invitation = invitation_query \ .options(selectinload(OrganisationInvitation.organisation).selectinload(Organisation.organisation_memberships) .selectinload(OrganisationMembership.user)) \ .options(selectinload(OrganisationInvitation.organisation).selectinload(Organisation.services)) \ .filter(OrganisationInvitation.hash == hash_value) \ .one() return organisation_invitation, 200
def attribute_aggregation(): confirm_read_access() edu_person_principal_name = query_param("edu_person_principal_name") email = query_param("email", required=False) users = User.query \ .join(User.collaboration_memberships) \ .join(CollaborationMembership.collaboration) \ .options(selectinload(User.collaboration_memberships) .selectinload(CollaborationMembership.collaboration)) \ .filter(or_(User.uid == edu_person_principal_name, User.email == email)) \ .all() # preference over edu_person_principal_name if len(users) == 0: return None, 404 users_eppn_match = list(filter(lambda u: u.uid == edu_person_principal_name, users)) user = users[0] if len(users) == 1 else users_eppn_match[0] if len(users_eppn_match) == 1 else users[0] return [cm.collaboration.name for cm in user.collaboration_memberships], 200
def my_collaborations_lite(): include_services = query_param("includeServices", False) user_id = current_user_id() query = Collaboration.query \ .join(Collaboration.collaboration_memberships) \ .options(selectinload(Collaboration.organisation)) if include_services: query = query \ .options(selectinload(Collaboration.services).selectinload(Service.allowed_organisations)) collaborations = query \ .filter(CollaborationMembership.user_id == user_id) \ .all() return collaborations, 200
def collaboration_by_identifier(): identifier = query_param("identifier") collaboration = Collaboration.query \ .outerjoin(Collaboration.collaboration_memberships) \ .outerjoin(CollaborationMembership.user) \ .options(selectinload(Collaboration.organisation).selectinload(Organisation.services)) \ .options(selectinload(Collaboration.services)) \ .options(selectinload(Collaboration.groups)) \ .options(selectinload(Collaboration.collaboration_memberships) .selectinload(CollaborationMembership.user)) \ .filter(Collaboration.identifier == identifier).one() collaboration_json = jsonify(collaboration).json service_emails = collaboration.service_emails() return {"collaboration": collaboration_json, "service_emails": service_emails}, 200
def members(): confirm_authorized_api_call() identifier = query_param("identifier") collaboration_group = aliased(Collaboration) collaboration_membership = aliased(Collaboration) users = User.query \ .options(load_only("uid", "name")) \ .join(User.collaboration_memberships) \ .join(collaboration_membership, CollaborationMembership.collaboration) \ .join(CollaborationMembership.groups) \ .join(collaboration_group, Group.collaboration) \ .filter(or_(collaboration_group.identifier == identifier, collaboration_membership.identifier == identifier)) \ .all() return users, 200
def collaboration_search(): confirm_allow_impersonation() res = [] q = query_param("q") if q and len(q): base_query = "SELECT id, name, description, organisation_id FROM collaborations " not_wild_card = "*" not in q if not_wild_card: q = replace_full_text_search_boolean_mode_chars(q) base_query += f"WHERE MATCH (name, description) AGAINST (:q IN BOOLEAN MODE) " \ f"AND id > 0 ORDER BY NAME LIMIT {full_text_search_autocomplete_limit}" sql = text(base_query if not_wild_card else base_query + " ORDER BY NAME") if not_wild_card: sql = sql.bindparams(bindparam("q", type_=String)) result_set = db.engine.execute(sql, {"q": f"{q}*"}) if not_wild_card else db.engine.execute(sql) res = [{"id": row[0], "name": row[1], "description": row[2], "organisation_id": row[3]} for row in result_set] return res, 200
def service_by_uuid4(): uuid4 = urllib.parse.unquote(query_param("uuid4")) user = User.query.get(current_user_id()) service = Service.query.filter(Service.uuid4 == uuid4).one() if not is_application_admin() and not user_service(service.id): raise Forbidden() service_emails = {} if service.contact_email: service_emails[service.id] = [service.contact_email] else: service_emails[service.id] = [membership.user.email for membership in service.service_memberships] collaborations = [] for cm in user.collaboration_memberships: if service.id in [s.id for s in cm.collaboration.services]: collaborations.append(cm.collaboration) return {"service": service, "collaborations": collaborations, "service_emails": service_emails}, 200
def organisation_search(): confirm_write_access(override_func=is_service_admin) res = [] q = query_param("q") if q and len(q): base_query = "SELECT id, name, description, category, logo, short_name FROM organisations " not_wild_card = "*" not in q if not_wild_card: q = replace_full_text_search_boolean_mode_chars(q) base_query += f"WHERE MATCH (name, description) AGAINST (:q IN BOOLEAN MODE) " \ f"AND id > 0 ORDER BY NAME LIMIT {full_text_search_autocomplete_limit}" sql = text(base_query if not_wild_card else base_query + " ORDER BY NAME") if not_wild_card: sql = sql.bindparams(bindparam("q", type_=String)) result_set = db.engine.execute(sql, {"q": f"{q}*"}) if not_wild_card else db.engine.execute(sql) res = [{"id": row[0], "name": row[1], "description": row[2], "category": row[3], "logo": row[4], "short_name": row[5]} for row in result_set] return res, 200
def short_name_exists(): name = query_param("short_name") organisation_id = int(query_param("organisation_id")) existing_collaboration = query_param("existing_collaboration", required=False, default="") res = _do_short_name_exists(name, organisation_id, existing_collaboration) return res, 200
def service_by_entity_id(): entity_id = urllib.parse.unquote(query_param("entity_id")) return Service.query \ .options(selectinload(Service.allowed_organisations)) \ .filter(Service.entity_id == entity_id) \ .one(), 200
def all_services(): include_counts = query_param("include_counts", required=False) return _do_get_services(include_counts=include_counts)
def user_search(): confirm_allow_impersonation() q = query_param("q") organisation_id = query_param("organisation_id", required=False) collaboration_id = query_param("collaboration_id", required=False) organisation_admins = query_param("organisation_admins", required=False) collaboration_admins = query_param("collaboration_admins", required=False) base_query = "SELECT u.id, u.uid, u.name, u.email, o.name, om.role, c.name, cm.role FROM users u " organisation_join = " INNER " if organisation_id or organisation_admins else "LEFT " base_query += f"{organisation_join} JOIN organisation_memberships om ON om.user_id = u.id " \ f"{organisation_join} JOIN organisations o ON o.id = om.organisation_id " collaboration_join = " INNER " if collaboration_id or collaboration_admins else "LEFT " base_query += f"{collaboration_join} JOIN collaboration_memberships cm ON cm.user_id = u.id " \ f"{collaboration_join} JOIN collaborations c ON c.id = cm.collaboration_id " base_query += " WHERE 1=1 " not_wild_card = q != "*" if not_wild_card: q = replace_full_text_search_boolean_mode_chars(q) base_query += "AND MATCH (u.name, u.email) AGAINST (:q IN BOOLEAN MODE) " \ "AND u.id > 0 " if organisation_id: base_query += f"AND om.organisation_id = {int(organisation_id)} " if collaboration_id: base_query += f"AND cm.collaboration_id = {int(collaboration_id)} " if organisation_admins: base_query += "AND om.role = 'admin'" if collaboration_admins: base_query += "AND cm.role = 'admin'" base_query += f" ORDER BY u.name LIMIT {full_text_search_autocomplete_limit}" sql = text(base_query) if not_wild_card: sql = sql.bindparams(bindparam("q", type_=String)) result_set = db.engine.execute(sql, {"q": f"{q}*"}) if not_wild_card else db.engine.execute(sql) data = [{"id": row[0], "uid": row[1], "name": row[2], "email": row[3], "organisation_name": row[4], "organisation_role": row[5], "collaboration_name": row[6], "collaboration_role": row[7]} for row in result_set] res = [] for key, group in itertools.groupby(data, lambda u: u["id"]): user_info = {"id": key, "organisations": [], "collaborations": []} for g in group: user_info["uid"] = g["uid"] user_info["name"] = g["name"] user_info["email"] = g["email"] user_info["admin"] = is_admin_user(g) if g["organisation_name"] is not None and g["organisation_name"] not in [item["name"] for item in user_info["organisations"]]: user_info["organisations"].append({"name": g["organisation_name"], "role": g["organisation_role"]}) if g["collaboration_name"] is not None and g["collaboration_name"] not in [item["name"] for item in user_info["collaborations"]]: user_info["collaborations"].append({"name": g["collaboration_name"], "role": g["collaboration_role"]}) res.append(user_info) return res, 200
def authorization(): state = query_param("state", required=False, default=None) authorization_endpoint = _get_authorization_url(state) return {"authorization_endpoint": authorization_endpoint}, 200
def resume_session(): logger = ctx_logger("oidc") cfg = current_app.app_config oidc_config = cfg.oidc code = query_param("code", required=False, default=None) if not code: # This means that we are not in the redirect callback, but at the redirect from eduTeams logger.debug("Redirect to login in resume-session to start OIDC flow") authorization_endpoint = _get_authorization_url() return redirect(authorization_endpoint) scopes = " ".join(oidc_config.scopes) payload = { "code": code, "grant_type": "authorization_code", "scope": scopes, "redirect_uri": oidc_config.redirect_uri } headers = { "Content-Type": "application/x-www-form-urlencoded", "Cache-Control": "no-cache", "Accept": "application/json, application/json;charset=UTF-8" } response = requests.post(oidc_config.token_endpoint, data=urllib.parse.urlencode(payload), headers=headers, auth=(oidc_config.client_id, oidc_config.client_secret)) if response.status_code != 200: return _redirect_with_error(logger, f"Server error: Token endpoint error (http {response.status_code}") token_json = response.json() access_token = token_json["access_token"] headers = { "Accept": "application/json, application/json;charset=UTF-8", "Authorization": f"Bearer {access_token}" } response = requests.get(oidc_config.userinfo_endpoint, headers=headers) if response.status_code != 200: return _redirect_with_error(logger, f"Server error: User info endpoint error (http {response.status_code}") logger = ctx_logger("user") user_info_json = response.json() logger.debug(f"Userinfo endpoint results {user_info_json}") uid = user_info_json["sub"] user = User.query.filter(User.uid == uid).first() if not user: user = User(uid=uid, created_by="system", updated_by="system") add_user_claims(user_info_json, uid, user) # last_login_date is set later in this method user.last_accessed_date = datetime.datetime.now() logger.info(f"Provisioning new user {user.uid}") else: logger.info(f"Updating user {user.uid} with new claims / updated at") add_user_claims(user_info_json, uid, user) encoded_id_token = token_json["id_token"] id_token = decode_jwt_token(encoded_id_token) no_mfa_required = not oidc_config.second_factor_authentication_required idp_mfa = id_token.get("acr") == ACR_VALUES idp_allowed = mfa_idp_allowed(user, user.schac_home_organisation, None) second_factor_confirmed = no_mfa_required or idp_mfa or idp_allowed if second_factor_confirmed: user.last_login_date = datetime.datetime.now() user = db.session.merge(user) db.session.commit() user_accepted_aup = user.has_agreed_with_aup() store_user_in_session(user, second_factor_confirmed, user_accepted_aup) if not user_accepted_aup: location = f"{cfg.base_url}/aup" elif not second_factor_confirmed: location = f"{cfg.base_url}/2fa" else: location = session.get("original_destination", cfg.base_url) return redirect(location)
def find_by_id(): confirm_write_access() return _user_query() \ .options(joinedload(User.service_aups) .subqueryload(ServiceAup.service)) \ .filter(User.id == query_param("id")).one(), 200