diff --git a/api/chalicelib/utils/email_handler.py b/api/chalicelib/utils/email_handler.py index 2de35e616..f7a7fd61b 100644 --- a/api/chalicelib/utils/email_handler.py +++ b/api/chalicelib/utils/email_handler.py @@ -10,7 +10,7 @@ from chalicelib.utils.helper import environ def __get_subject(subject): - return subject if helper.is_production() else f"{helper.get_stage_name()}: {subject}" + return subject def __get_html_from_file(source, formatting_variables): diff --git a/ee/api/.chalice/config.json b/ee/api/.chalice/config.json index b00e87d4a..761a8cfa7 100644 --- a/ee/api/.chalice/config.json +++ b/ee/api/.chalice/config.json @@ -55,7 +55,13 @@ "S3_HOST": "", "S3_KEY": "", "S3_SECRET": "", - "version_number": "1.0.0" + "version_number": "1.0.0", + "LICENSE_KEY": "", + "SAML2_MD_URL": "", + "idp_entityId": "", + "idp_sso_url": "", + "idp_x509cert": "", + "idp_sls_url": "" }, "lambda_timeout": 150, "lambda_memory_size": 400, diff --git a/ee/api/.gitignore b/ee/api/.gitignore index 3f3e5d811..a526e1c21 100644 --- a/ee/api/.gitignore +++ b/ee/api/.gitignore @@ -175,4 +175,60 @@ SUBNETS.json chalicelib/.config chalicelib/saas README/* -Pipfile \ No newline at end of file +Pipfile + +/chalicelib/core/alerts.py +/chalicelib/core/announcements.py +/chalicelib/blueprints/bp_app_api.py +/chalicelib/blueprints/bp_core.py +/chalicelib/blueprints/bp_core_crons.py +/chalicelib/core/collaboration_slack.py +/chalicelib/core/errors_favorite_viewed.py +/chalicelib/core/events.py +/chalicelib/core/events_ios.py +/chalicelib/core/integration_base.py +/chalicelib/core/integration_base_issue.py +/chalicelib/core/integration_github.py +/chalicelib/core/integration_github_issue.py +/chalicelib/core/integration_jira_cloud.py +/chalicelib/core/integration_jira_cloud_issue.py +/chalicelib/core/integrations_manager.py +/chalicelib/core/issues.py +/chalicelib/core/jobs.py +/chalicelib/core/log_tool_bugsnag.py +/chalicelib/core/log_tool_cloudwatch.py +/chalicelib/core/log_tool_datadog.py +/chalicelib/core/log_tool_elasticsearch.py +/chalicelib/core/log_tool_newrelic.py +/chalicelib/core/log_tool_rollbar.py +/chalicelib/core/log_tool_sentry.py +/chalicelib/core/log_tool_stackdriver.py +/chalicelib/core/log_tool_sumologic.py +/chalicelib/core/sessions_assignments.py +/chalicelib/core/sessions_favorite_viewed.py +/chalicelib/core/sessions_metas.py +/chalicelib/core/sessions_mobs.py +/chalicelib/core/significance.py +/chalicelib/core/slack.py +/chalicelib/core/socket_ios.py +/chalicelib/core/sourcemaps.py +/chalicelib/core/sourcemaps_parser.py +/chalicelib/core/weekly_report.py +/chalicelib/saml +/chalicelib/utils/html/ +/chalicelib/utils/__init__.py +/chalicelib/utils/args_transformer.py +/chalicelib/utils/captcha.py +/chalicelib/utils/dev.py +/chalicelib/utils/email_handler.py +/chalicelib/utils/email_helper.py +/chalicelib/utils/event_filter_definition.py +/chalicelib/utils/github_client_v3.py +/chalicelib/utils/helper.py +/chalicelib/utils/jira_client.py +/chalicelib/utils/metrics_helper.py +/chalicelib/utils/pg_client.py +/chalicelib/utils/s3.py +/chalicelib/utils/smtp.py +/chalicelib/utils/strings.py +/chalicelib/utils/TimeUTC.py diff --git a/ee/api/app.py b/ee/api/app.py index 9a181e8e7..186da273c 100644 --- a/ee/api/app.py +++ b/ee/api/app.py @@ -11,7 +11,7 @@ from chalicelib.utils import helper from chalicelib.utils import pg_client from chalicelib.utils.helper import environ -from chalicelib.blueprints import bp_ee, bp_ee_crons +from chalicelib.blueprints import bp_ee, bp_ee_crons, bp_saml app = Chalice(app_name='parrot') app.debug = not helper.is_production() or helper.is_local() @@ -59,17 +59,11 @@ _overrides.chalice_app(app) @app.middleware('http') def or_middleware(event, get_response): - from chalicelib.ee import unlock + from chalicelib.core import unlock if not unlock.is_valid(): return Response(body={"errors": ["expired license"]}, status_code=403) if "{projectid}" in event.path.lower(): - from chalicelib.ee import projects - print("==================================") - print(event.context["authorizer"].get("authorizer_identity")) - print(event.uri_params["projectId"]) - print(projects.get_internal_project_id(event.uri_params["projectId"])) - print(event.context["authorizer"]["tenantId"]) - print("==================================") + from chalicelib.core import projects if event.context["authorizer"].get("authorizer_identity") == "api_key" \ and not projects.is_authorized( project_id=projects.get_internal_project_id(event.uri_params["projectId"]), @@ -126,3 +120,4 @@ app.register_blueprint(bp_dashboard.app) # Enterprise app.register_blueprint(bp_ee.app) app.register_blueprint(bp_ee_crons.app) +app.register_blueprint(bp_saml.app) diff --git a/ee/api/chalicelib/blueprints/bp_authorizers.py b/ee/api/chalicelib/blueprints/bp_authorizers.py index 9fcb8e475..14abd3988 100644 --- a/ee/api/chalicelib/blueprints/bp_authorizers.py +++ b/ee/api/chalicelib/blueprints/bp_authorizers.py @@ -2,7 +2,7 @@ from chalice import Blueprint, AuthResponse from chalicelib.utils import helper from chalicelib.core import authorizers -from chalicelib.ee import users +from chalicelib.core import users app = Blueprint(__name__) diff --git a/ee/api/chalicelib/blueprints/bp_core_dynamic.py b/ee/api/chalicelib/blueprints/bp_core_dynamic.py index b8bb7fc87..98a1b8b29 100644 --- a/ee/api/chalicelib/blueprints/bp_core_dynamic.py +++ b/ee/api/chalicelib/blueprints/bp_core_dynamic.py @@ -3,19 +3,19 @@ from chalice import Blueprint, Response from chalicelib import _overrides from chalicelib.core import metadata, errors_favorite_viewed, slack, alerts, sessions, integration_github, \ integrations_manager -from chalicelib.utils import captcha +from chalicelib.utils import captcha, SAML2_helper from chalicelib.utils import helper from chalicelib.utils.helper import environ -from chalicelib.ee import tenants -from chalicelib.ee import signup -from chalicelib.ee import users -from chalicelib.ee import projects -from chalicelib.ee import errors -from chalicelib.ee import notifications -from chalicelib.ee import boarding -from chalicelib.ee import webhook -from chalicelib.ee import license +from chalicelib.core import tenants +from chalicelib.core import signup +from chalicelib.core import users +from chalicelib.core import projects +from chalicelib.core import errors +from chalicelib.core import notifications +from chalicelib.core import boarding +from chalicelib.core import webhook +from chalicelib.core import license from chalicelib.core.collaboration_slack import Slack app = Blueprint(__name__) @@ -41,6 +41,8 @@ def login(): return { 'errors': ['You’ve entered invalid Email or Password.'] } + elif "errors" in r: + return r tenant_id = r.pop("tenantId") # change this in open-source @@ -74,7 +76,8 @@ def get_account(context): "metadata": metadata.get_remaining_metadata_with_count(context['tenantId']) }, **license.get_status(context["tenantId"]), - "smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0 + "smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0, + "saml2": SAML2_helper.is_saml2_available() } } @@ -350,6 +353,8 @@ def get_members(context): @app.route('/client/members', methods=['PUT', 'POST']) def add_member(context): + if SAML2_helper.is_saml2_available(): + return {"errors": ["please use your SSO server to add teammates"]} data = app.current_request.json_body return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data) diff --git a/ee/api/chalicelib/blueprints/bp_core_dynamic_crons.py b/ee/api/chalicelib/blueprints/bp_core_dynamic_crons.py index 855af25df..b149c8807 100644 --- a/ee/api/chalicelib/blueprints/bp_core_dynamic_crons.py +++ b/ee/api/chalicelib/blueprints/bp_core_dynamic_crons.py @@ -4,8 +4,8 @@ from chalicelib.utils import helper app = Blueprint(__name__) _overrides.chalice_app(app) -from chalicelib.ee import telemetry -from chalicelib.ee import unlock +from chalicelib.core import telemetry +from chalicelib.core import unlock # Run every day. diff --git a/ee/api/chalicelib/blueprints/bp_ee.py b/ee/api/chalicelib/blueprints/bp_ee.py index 38ad071f4..a0fa0aa8c 100644 --- a/ee/api/chalicelib/blueprints/bp_ee.py +++ b/ee/api/chalicelib/blueprints/bp_ee.py @@ -1,7 +1,7 @@ from chalice import Blueprint from chalicelib import _overrides -from chalicelib.ee import unlock +from chalicelib.core import unlock app = Blueprint(__name__) _overrides.chalice_app(app) diff --git a/ee/api/chalicelib/blueprints/bp_saml.py b/ee/api/chalicelib/blueprints/bp_saml.py new file mode 100644 index 000000000..d5a964211 --- /dev/null +++ b/ee/api/chalicelib/blueprints/bp_saml.py @@ -0,0 +1,188 @@ +from chalice import Blueprint + +from chalicelib import _overrides +from chalicelib.utils.SAML2_helper import prepare_request, init_saml_auth + +app = Blueprint(__name__) +_overrides.chalice_app(app) + +from chalicelib.utils.helper import environ + +from onelogin.saml2.auth import OneLogin_Saml2_Logout_Request +from onelogin.saml2.utils import OneLogin_Saml2_Utils + +from chalice import Response +from chalicelib.core import users, tenants + + +@app.route("/saml2", methods=['GET'], authorizer=None) +def start_sso(): + app.current_request.path = '' + req = prepare_request(request=app.current_request) + auth = init_saml_auth(req) + sso_built_url = auth.login() + return Response( + # status_code=301, + status_code=307, + body='', + headers={'Location': sso_built_url, 'Content-Type': 'text/plain'}) + + +@app.route('/saml2/acs', methods=['POST'], content_types=['application/x-www-form-urlencoded'], authorizer=None) +def process_sso_assertion(): + req = prepare_request(request=app.current_request) + session = req["cookie"]["session"] + request = req['request'] + auth = init_saml_auth(req) + + request_id = None + if 'AuthNRequestID' in session: + request_id = session['AuthNRequestID'] + + auth.process_response(request_id=request_id) + errors = auth.get_errors() + user_data = {} + if len(errors) == 0: + if 'AuthNRequestID' in session: + del session['AuthNRequestID'] + user_data = auth.get_attributes() + # session['samlUserdata'] = user_data + # session['samlNameId'] = auth.get_nameid() + # session['samlNameIdFormat'] = auth.get_nameid_format() + # session['samlNameIdNameQualifier'] = auth.get_nameid_nq() + # session['samlNameIdSPNameQualifier'] = auth.get_nameid_spnq() + # session['samlSessionIndex'] = auth.get_session_index() + # session['samlSessionExpiration'] = auth.get_session_expiration() + # print('>>>>') + # print(session) + self_url = OneLogin_Saml2_Utils.get_self_url(req) + if 'RelayState' in request.form and self_url != request.form['RelayState']: + print("====>redirect") + return Response( + status_code=307, + body='', + headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'}) + elif auth.get_settings().is_debug_active(): + error_reason = auth.get_last_error_reason() + return {"errors": [error_reason]} + + email = auth.get_nameid() + existing = users.get_by_email_only(auth.get_nameid()) + + internal_id = next(iter(user_data.get("internalId", [])), None) + if len(existing) == 0 or existing[0].get("origin") != 'saml': + tenant_key = user_data.get("tenantKey", []) + if len(tenant_key) == 0: + print("tenantKey not present in assertion") + return Response( + status_code=307, + body={"errors": ["tenantKey not present in assertion"]}, + headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'}) + else: + t = tenants.get_by_tenant_key(tenant_key[0]) + if t is None: + return Response( + status_code=307, + body={"errors": ["Unknown tenantKey"]}, + headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'}) + if len(existing) == 0: + print("== new user ==") + users.create_sso_user(tenant_id=t['tenantId'], email=email, admin=True, origin='saml', + name=" ".join(user_data.get("firstName", []) + user_data.get("lastName", [])), + internal_id=internal_id) + else: + existing = existing[0] + if existing.get("origin") != 'saml': + print("== migrating user to SAML ==") + users.update(tenant_id=t['tenantId'], user_id=existing["id"], + changes={"origin": 'saml', "internal_id": internal_id}) + + return users.authenticate_sso(email=email, internal_id=internal_id, exp=auth.get_session_expiration()) + + +@app.route('/saml2/slo', methods=['GET']) +def process_slo_request(context): + req = prepare_request(request=app.current_request) + session = req["cookie"]["session"] + request = req['request'] + auth = init_saml_auth(req) + + name_id = session_index = name_id_format = name_id_nq = name_id_spnq = None + if 'samlNameId' in session: + name_id = session['samlNameId'] + if 'samlSessionIndex' in session: + session_index = session['samlSessionIndex'] + if 'samlNameIdFormat' in session: + name_id_format = session['samlNameIdFormat'] + if 'samlNameIdNameQualifier' in session: + name_id_nq = session['samlNameIdNameQualifier'] + if 'samlNameIdSPNameQualifier' in session: + name_id_spnq = session['samlNameIdSPNameQualifier'] + users.change_jwt_iat(context["userId"]) + return Response( + status_code=307, + body='', + headers={'Location': auth.logout(name_id=name_id, session_index=session_index, nq=name_id_nq, + name_id_format=name_id_format, + spnq=name_id_spnq), 'Content-Type': 'text/plain'}) + + +@app.route('/saml2/sls', methods=['GET'], authorizer=None) +def process_sls_assertion(): + req = prepare_request(request=app.current_request) + session = req["cookie"]["session"] + request = req['request'] + auth = init_saml_auth(req) + request_id = None + if 'LogoutRequestID' in session: + request_id = session['LogoutRequestID'] + + def dscb(): + session.clear() + + url = auth.process_slo(request_id=request_id, delete_session_cb=dscb) + + errors = auth.get_errors() + if len(errors) == 0: + if 'SAMLRequest' in req['get_data']: + logout_request = OneLogin_Saml2_Logout_Request(auth.get_settings(), req['get_data']['SAMLRequest']) + user_email = logout_request.get_nameid(auth.get_last_request_xml()) + to_logout = users.get_by_email_only(user_email) + + if len(to_logout) > 0: + to_logout = to_logout[0]['id'] + users.change_jwt_iat(to_logout) + else: + print("Unknown user SLS-Request By IdP") + else: + print("Preprocessed SLS-Request by SP") + + if url is not None: + return Response( + status_code=307, + body='', + headers={'Location': url, 'Content-Type': 'text/plain'}) + + return Response( + status_code=307, + body='', + headers={'Location': environ["SITE_URL"], 'Content-Type': 'text/plain'}) + + +@app.route('/saml2/metadata', methods=['GET'], authorizer=None) +def saml2_metadata(): + req = prepare_request(request=app.current_request) + auth = init_saml_auth(req) + settings = auth.get_settings() + metadata = settings.get_sp_metadata() + errors = settings.validate_metadata(metadata) + + if len(errors) == 0: + return Response( + status_code=200, + body=metadata, + headers={'Content-Type': 'text/xml'}) + else: + return Response( + status_code=500, + body=', '.join(errors)) diff --git a/ee/api/chalicelib/blueprints/subs/bp_dashboard.py b/ee/api/chalicelib/blueprints/subs/bp_dashboard.py index e14dd1b94..b868f7c64 100644 --- a/ee/api/chalicelib/blueprints/subs/bp_dashboard.py +++ b/ee/api/chalicelib/blueprints/subs/bp_dashboard.py @@ -2,7 +2,7 @@ from chalice import Blueprint from chalicelib.utils import helper from chalicelib import _overrides -from chalicelib.ee import dashboard +from chalicelib.core import dashboard from chalicelib.core import metadata diff --git a/ee/api/chalicelib/core/authorizers.py b/ee/api/chalicelib/core/authorizers.py index 8d6d69dfb..f7f50f52b 100644 --- a/ee/api/chalicelib/core/authorizers.py +++ b/ee/api/chalicelib/core/authorizers.py @@ -3,8 +3,8 @@ import jwt from chalicelib.utils import helper from chalicelib.utils.TimeUTC import TimeUTC -from chalicelib.ee import tenants -from chalicelib.ee import users +from chalicelib.core import tenants +from chalicelib.core import users def jwt_authorizer(token): @@ -38,12 +38,13 @@ def jwt_context(context): } -def generate_jwt(id, tenant_id, iat, aud): +def generate_jwt(id, tenant_id, iat, aud, exp=None): token = jwt.encode( payload={ "userId": id, "tenantId": tenant_id, - "exp": iat // 1000 + int(environ["jwt_exp_delta_seconds"]) + TimeUTC.get_utc_offset() // 1000, + "exp": iat // 1000 + int(environ["jwt_exp_delta_seconds"]) + TimeUTC.get_utc_offset() // 1000 \ + if exp is None else exp, "iss": environ["jwt_issuer"], "iat": iat // 1000, "aud": aud @@ -58,4 +59,4 @@ def api_key_authorizer(token): t = tenants.get_by_api_key(token) if t is not None: t["createdAt"] = TimeUTC.datetime_to_timestamp(t["createdAt"]) - return t \ No newline at end of file + return t diff --git a/ee/api/chalicelib/ee/boarding.py b/ee/api/chalicelib/core/boarding.py similarity index 98% rename from ee/api/chalicelib/ee/boarding.py rename to ee/api/chalicelib/core/boarding.py index 08917e4b6..6690e59f2 100644 --- a/ee/api/chalicelib/ee/boarding.py +++ b/ee/api/chalicelib/core/boarding.py @@ -1,8 +1,8 @@ from chalicelib.utils import pg_client from chalicelib.core import log_tool_datadog, log_tool_stackdriver, log_tool_sentry -from chalicelib.ee import users -from chalicelib.ee import projects +from chalicelib.core import users +from chalicelib.core import projects def get_state(tenant_id): diff --git a/ee/api/chalicelib/ee/dashboard.py b/ee/api/chalicelib/core/dashboard.py similarity index 99% rename from ee/api/chalicelib/ee/dashboard.py rename to ee/api/chalicelib/core/dashboard.py index 878b5119a..c5c373c78 100644 --- a/ee/api/chalicelib/ee/dashboard.py +++ b/ee/api/chalicelib/core/dashboard.py @@ -5,7 +5,7 @@ from chalicelib.utils import pg_client from chalicelib.utils import args_transformer from chalicelib.utils import helper from chalicelib.utils.TimeUTC import TimeUTC -from chalicelib.ee.utils import ch_client +from chalicelib.utils import ch_client from math import isnan from chalicelib.utils.metrics_helper import __get_step_size diff --git a/ee/api/chalicelib/ee/errors.py b/ee/api/chalicelib/core/errors.py similarity index 99% rename from ee/api/chalicelib/ee/errors.py rename to ee/api/chalicelib/core/errors.py index 1faa55a77..7c2a08447 100644 --- a/ee/api/chalicelib/ee/errors.py +++ b/ee/api/chalicelib/core/errors.py @@ -1,9 +1,9 @@ import json from chalicelib.utils import pg_client, helper -from chalicelib.ee.utils import ch_client +from chalicelib.utils import ch_client from chalicelib.core import sourcemaps, sessions -from chalicelib.ee import dashboard +from chalicelib.core import dashboard from chalicelib.utils.TimeUTC import TimeUTC diff --git a/ee/api/chalicelib/ee/license.py b/ee/api/chalicelib/core/license.py similarity index 95% rename from ee/api/chalicelib/ee/license.py rename to ee/api/chalicelib/core/license.py index d1e90b809..caf107dc7 100644 --- a/ee/api/chalicelib/ee/license.py +++ b/ee/api/chalicelib/core/license.py @@ -1,5 +1,5 @@ from chalicelib.utils import pg_client -from chalicelib.ee import unlock +from chalicelib.core import unlock def get_status(tenant_id): diff --git a/ee/api/chalicelib/core/metadata.py b/ee/api/chalicelib/core/metadata.py index 47729c30b..84bc39dec 100644 --- a/ee/api/chalicelib/core/metadata.py +++ b/ee/api/chalicelib/core/metadata.py @@ -1,6 +1,6 @@ from chalicelib.utils import pg_client, helper, dev -from chalicelib.ee import projects +from chalicelib.core import projects import re diff --git a/ee/api/chalicelib/ee/notifications.py b/ee/api/chalicelib/core/notifications.py similarity index 100% rename from ee/api/chalicelib/ee/notifications.py rename to ee/api/chalicelib/core/notifications.py diff --git a/ee/api/chalicelib/ee/projects.py b/ee/api/chalicelib/core/projects.py similarity index 99% rename from ee/api/chalicelib/ee/projects.py rename to ee/api/chalicelib/core/projects.py index b90350530..dc9cbfd23 100644 --- a/ee/api/chalicelib/ee/projects.py +++ b/ee/api/chalicelib/core/projects.py @@ -1,6 +1,6 @@ import json -from chalicelib.ee import users +from chalicelib.core import users from chalicelib.utils import pg_client, helper from chalicelib.utils.TimeUTC import TimeUTC diff --git a/ee/api/chalicelib/core/reset_password.py b/ee/api/chalicelib/core/reset_password.py index 144d22469..85588bf73 100644 --- a/ee/api/chalicelib/core/reset_password.py +++ b/ee/api/chalicelib/core/reset_password.py @@ -3,7 +3,7 @@ from chalicelib.utils import email_helper, captcha, helper import secrets from chalicelib.utils import pg_client -from chalicelib.ee import users +from chalicelib.core import users def step1(data): diff --git a/ee/api/chalicelib/ee/resources.py b/ee/api/chalicelib/core/resources.py similarity index 95% rename from ee/api/chalicelib/ee/resources.py rename to ee/api/chalicelib/core/resources.py index 575c604a5..557135804 100644 --- a/ee/api/chalicelib/ee/resources.py +++ b/ee/api/chalicelib/core/resources.py @@ -1,5 +1,5 @@ from chalicelib.utils import helper -from chalicelib.ee.utils import ch_client +from chalicelib.utils import ch_client from chalicelib.utils.TimeUTC import TimeUTC diff --git a/ee/api/chalicelib/core/sessions.py b/ee/api/chalicelib/core/sessions.py index aab67f31c..53d1d9383 100644 --- a/ee/api/chalicelib/core/sessions.py +++ b/ee/api/chalicelib/core/sessions.py @@ -2,9 +2,9 @@ from chalicelib.utils import pg_client, helper from chalicelib.utils import dev from chalicelib.core import events, sessions_metas, socket_ios, metadata, events_ios, sessions_mobs, issues -from chalicelib.ee import projects, errors +from chalicelib.core import projects, errors -from chalicelib.ee import resources +from chalicelib.core import resources SESSION_PROJECTION_COLS = """s.project_id, s.session_id::text AS session_id, diff --git a/ee/api/chalicelib/ee/signup.py b/ee/api/chalicelib/core/signup.py similarity index 96% rename from ee/api/chalicelib/ee/signup.py rename to ee/api/chalicelib/core/signup.py index 01fbee68a..7606c8b0a 100644 --- a/ee/api/chalicelib/ee/signup.py +++ b/ee/api/chalicelib/core/signup.py @@ -1,6 +1,6 @@ from chalicelib.utils import helper from chalicelib.utils import pg_client -from chalicelib.ee import users, telemetry +from chalicelib.core import users, telemetry from chalicelib.utils import captcha import json from chalicelib.utils.TimeUTC import TimeUTC @@ -57,7 +57,8 @@ def create_step1(data): signed_ups = get_signed_ups() if len(signed_ups) == 0 and data.get("tenantId") is not None \ - or len(signed_ups) > 0 and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]: + or len(signed_ups) > 0 and data.get("tenantId") is not None\ + and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]: errors.append("Tenant does not exist") if len(errors) > 0: print("==> error") @@ -156,4 +157,4 @@ def create_step1(data): "user": r, "client": c, } - } + } \ No newline at end of file diff --git a/ee/api/chalicelib/ee/telemetry.py b/ee/api/chalicelib/core/telemetry.py similarity index 99% rename from ee/api/chalicelib/ee/telemetry.py rename to ee/api/chalicelib/core/telemetry.py index a45ab789b..d9843e37d 100644 --- a/ee/api/chalicelib/ee/telemetry.py +++ b/ee/api/chalicelib/core/telemetry.py @@ -65,4 +65,4 @@ def new_client(tenant_id): FROM public.tenants WHERE tenant_id=%(tenant_id)s;""", {"tenant_id": tenant_id})) data = cur.fetchone() - requests.post('https://parrot.asayer.io/os/signup', json=process_data(data, edition='ee')) + requests.post('https://parrot.asayer.io/os/signup', json=process_data(data, edition='ee')) \ No newline at end of file diff --git a/ee/api/chalicelib/ee/tenants.py b/ee/api/chalicelib/core/tenants.py similarity index 80% rename from ee/api/chalicelib/ee/tenants.py rename to ee/api/chalicelib/core/tenants.py index 38db9e653..7855e2e81 100644 --- a/ee/api/chalicelib/ee/tenants.py +++ b/ee/api/chalicelib/core/tenants.py @@ -1,6 +1,26 @@ from chalicelib.utils import pg_client from chalicelib.utils import helper -from chalicelib.ee import users +from chalicelib.core import users + + +def get_by_tenant_key(tenant_key): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + f"""SELECT + t.tenant_id, + t.name, + t.api_key, + t.created_at, + t.edition, + t.version_number, + t.opt_out + FROM public.tenants AS t + WHERE t.user_id = %(user_id)s AND t.deleted_at ISNULL + LIMIT 1;""", + {"user_id": tenant_key}) + ) + return helper.dict_to_camel_case(cur.fetchone()) def get_by_tenant_id(tenant_id): @@ -14,7 +34,8 @@ def get_by_tenant_id(tenant_id): t.created_at, t.edition, t.version_number, - t.opt_out + t.opt_out, + t.user_id AS tenant_key FROM public.tenants AS t WHERE t.tenant_id = %(tenantId)s AND t.deleted_at ISNULL LIMIT 1;""", diff --git a/ee/api/chalicelib/ee/unlock.py b/ee/api/chalicelib/core/unlock.py similarity index 100% rename from ee/api/chalicelib/ee/unlock.py rename to ee/api/chalicelib/core/unlock.py diff --git a/ee/api/chalicelib/ee/users.py b/ee/api/chalicelib/core/users.py similarity index 83% rename from ee/api/chalicelib/ee/users.py rename to ee/api/chalicelib/core/users.py index 13fe68e54..3331245a8 100644 --- a/ee/api/chalicelib/ee/users.py +++ b/ee/api/chalicelib/core/users.py @@ -1,15 +1,12 @@ import json -import time from chalicelib.core import authorizers - +from chalicelib.core import tenants from chalicelib.utils import helper from chalicelib.utils import pg_client from chalicelib.utils.TimeUTC import TimeUTC from chalicelib.utils.helper import environ -from chalicelib.ee import tenants - def create_new_member(tenant_id, email, password, admin, name, owner=False): with pg_client.PostgresClient() as cur: @@ -203,7 +200,8 @@ def get(user_id, tenant_id): (CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin, (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member, appearance, - api_key + api_key, + origin FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id WHERE users.user_id = %(userId)s @@ -274,7 +272,8 @@ def get_by_email_only(email): basic_authentication.generated_password, (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member + (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, + origin FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id WHERE users.email = %(email)s @@ -363,6 +362,8 @@ def change_password(tenant_id, user_id, email, old_password, new_password): item = get(tenant_id=tenant_id, user_id=user_id) if item is None: return {"errors": ["access denied"]} + if item["origin"] is not None: + return {"errors": ["cannot change your password because you are logged-in form an SSO service"]} if old_password == new_password: return {"errors": ["old and new password are the same"]} auth = authenticate(email, old_password, for_change_password=True) @@ -437,9 +438,19 @@ def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud): ) +def change_jwt_iat(user_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + f"""UPDATE public.users + SET jwt_iat = timezone('utc'::text, now()) + WHERE user_id = %(user_id)s + RETURNING jwt_iat;""", + {"user_id": user_id}) + cur.execute(query) + return cur.fetchone().get("jwt_iat") + + def authenticate(email, password, for_change_password=False, for_plugin=False): - if helper.TRACK_TIME: - now = int(time.time() * 1000) with pg_client.PostgresClient() as cur: query = cur.mogrify( f"""SELECT @@ -451,7 +462,8 @@ def authenticate(email, password, for_change_password=False, for_plugin=False): (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, - users.appearance + users.appearance, + users.origin FROM public.users AS users INNER JOIN public.basic_authentication USING(user_id) WHERE users.email = %(email)s AND basic_authentication.password = crypt(%(password)s, basic_authentication.password) @@ -461,13 +473,45 @@ def authenticate(email, password, for_change_password=False, for_plugin=False): cur.execute(query) r = cur.fetchone() - if helper.TRACK_TIME: - now2 = int(time.time() * 1000) - print(f"=====> authentication query&fetch in: {now2 - now} ms") - now = now2 + if r is not None: + if r["origin"] is not None: + return {"errors": ["must sign-in with SSO"]} + if for_change_password: + return True + r = helper.dict_to_camel_case(r, ignore_keys=["appearance"]) + jwt_iat = change_jwt_iat(r['id']) + return { + "jwt": authorizers.generate_jwt(r['id'], r['tenantId'], + TimeUTC.datetime_to_timestamp(jwt_iat), + aud=f"plugin:{helper.get_stage_name()}" if for_plugin else f"front:{helper.get_stage_name()}"), + "email": email, + **r + } + return None + + +def authenticate_sso(email, internal_id, exp=None): + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + f"""SELECT + users.user_id AS id, + users.tenant_id, + users.role, + users.name, + False AS change_password, + (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, + (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, + (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, + users.appearance, + origin + FROM public.users AS users + WHERE users.email = %(email)s AND internal_id = %(internal_id)s;""", + {"email": email, "internal_id": internal_id}) + + cur.execute(query) + r = cur.fetchone() + if r is not None: - if for_change_password: - return True r = helper.dict_to_camel_case(r, ignore_keys=["appearance"]) query = cur.mogrify( f"""UPDATE public.users @@ -479,8 +523,37 @@ def authenticate(email, password, for_change_password=False, for_plugin=False): return { "jwt": authorizers.generate_jwt(r['id'], r['tenantId'], TimeUTC.datetime_to_timestamp(cur.fetchone()["jwt_iat"]), - aud=f"plugin:{helper.get_stage_name()}" if for_plugin else f"front:{helper.get_stage_name()}"), + aud=f"front:{helper.get_stage_name()}", + exp=exp), "email": email, **r } return None + + +def create_sso_user(tenant_id, email, admin, name, origin, internal_id=None): + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""\ + WITH u AS ( + INSERT INTO public.users (tenant_id, email, role, name, data, origin, internal_id) + VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s) + RETURNING * + ) + SELECT u.user_id AS id, + u.email, + u.role, + u.name, + TRUE AS change_password, + (CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, + (CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, + (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, + u.appearance, + origin + FROM u;""", + {"tenantId": tenant_id, "email": email, "internal_id": internal_id, + "role": "admin" if admin else "member", "name": name, "origin": origin, + "data": json.dumps({"lastAnnouncementView": TimeUTC.now()})}) + cur.execute( + query + ) + return helper.dict_to_camel_case(cur.fetchone()) diff --git a/ee/api/chalicelib/ee/webhook.py b/ee/api/chalicelib/core/webhook.py similarity index 100% rename from ee/api/chalicelib/ee/webhook.py rename to ee/api/chalicelib/core/webhook.py diff --git a/ee/api/chalicelib/ee/__init__.py b/ee/api/chalicelib/ee/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ee/api/chalicelib/ee/utils/__init__.py b/ee/api/chalicelib/ee/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ee/api/chalicelib/utils/SAML2_helper.py b/ee/api/chalicelib/utils/SAML2_helper.py new file mode 100644 index 000000000..af4612005 --- /dev/null +++ b/ee/api/chalicelib/utils/SAML2_helper.py @@ -0,0 +1,104 @@ +from http import cookies +from urllib.parse import urlparse, parse_qsl + +from onelogin.saml2.auth import OneLogin_Saml2_Auth + +from chalicelib.utils.helper import environ + +SAML2 = { + "strict": True, + "debug": True, + "sp": { + "entityId": environ["SITE_URL"] + "/api/saml2/metadata/", + "assertionConsumerService": { + "url": environ["SITE_URL"] + "/api/saml2/acs", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }, + "singleLogoutService": { + "url": environ["SITE_URL"] + "/api/saml2/sls", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "x509cert": "", + "privateKey": "" + }, + "idp": None +} +idp = None +# SAML2 config handler +if len(environ.get("SAML2_MD_URL")) > 0: + print("SAML2_MD_URL provided, getting IdP metadata config") + from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser + + idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(environ.get("SAML2_MD_URL")) + idp = idp_data.get("idp") + +if SAML2["idp"] is None: + if len(environ.get("idp_entityId", "")) > 0 \ + and len(environ.get("idp_sso_url", "")) > 0 \ + and len(environ.get("idp_x509cert", "")) > 0: + idp = { + "entityId": environ["idp_entityId"], + "singleSignOnService": { + "url": environ["idp_sso_url"], + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "x509cert": environ["idp_x509cert"] + } + if len(environ.get("idp_sls_url", "")) > 0: + idp["singleLogoutService"] = { + "url": environ["idp_sls_url"], + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + } + +if idp is None: + print("No SAML2 IdP configuration found") +else: + SAML2["idp"] = idp + + +def init_saml_auth(req): + # auth = OneLogin_Saml2_Auth(req, custom_base_path=environ['SAML_PATH']) + + if idp is None: + raise Exception("No SAML2 config provided") + auth = OneLogin_Saml2_Auth(req, old_settings=SAML2) + + return auth + + +def prepare_request(request): + request.args = dict(request.query_params).copy() if request.query_params else {} + request.form = dict(request.json_body).copy() if request.json_body else dict( + parse_qsl(request.raw_body.decode())) if request.raw_body else {} + cookie_str = request.headers.get("cookie", "") + if "session" in cookie_str: + cookie = cookies.SimpleCookie() + cookie.load(cookie_str) + # Even though SimpleCookie is dictionary-like, it internally uses a Morsel object + # which is incompatible with requests. Manually construct a dictionary instead. + extracted_cookies = {} + for key, morsel in cookie.items(): + extracted_cookies[key] = morsel.value + session = extracted_cookies["session"] + else: + session = {} + # If server is behind proxys or balancers use the HTTP_X_FORWARDED fields + headers = request.headers + url_data = urlparse('%s://%s' % (headers.get('x-forwarded-proto', 'http'), headers['host'])) + return { + 'https': 'on' if request.headers.get('x-forwarded-proto', 'http') == 'https' else 'off', + 'http_host': request.headers['host'], + 'server_port': url_data.port, + 'script_name': request.path, + 'get_data': request.args.copy(), + # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 + # 'lowercase_urlencoding': True, + 'post_data': request.form.copy(), + 'cookie': {"session": session}, + 'request': request + } + + +def is_saml2_available(): + return idp is not None diff --git a/ee/api/chalicelib/ee/utils/ch_client.py b/ee/api/chalicelib/utils/ch_client.py similarity index 100% rename from ee/api/chalicelib/ee/utils/ch_client.py rename to ee/api/chalicelib/utils/ch_client.py diff --git a/ee/api/requirements.txt b/ee/api/requirements.txt index 4fa698105..2a31fc27a 100644 --- a/ee/api/requirements.txt +++ b/ee/api/requirements.txt @@ -9,4 +9,5 @@ elasticsearch==7.9.1 jira==2.0.0 schedule==1.1.0 croniter==1.0.12 -clickhouse-driver==0.1.5 \ No newline at end of file +clickhouse-driver==0.1.5 +python3-saml==1.10.1 \ No newline at end of file diff --git a/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql b/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql index fe36c6012..323774ed1 100644 --- a/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql +++ b/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql @@ -46,6 +46,7 @@ CREATE TABLE tenants ); CREATE TYPE user_role AS ENUM ('owner', 'admin', 'member'); +CREATE TYPE user_origin AS ENUM ('saml'); CREATE TABLE users ( user_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, @@ -119,7 +120,9 @@ CREATE TABLE users api_key text UNIQUE default generate_api_key(20) not null, jwt_iat timestamp without time zone NULL DEFAULT NULL, data jsonb NOT NULL DEFAULT '{}'::jsonb, - weekly_report boolean NOT NULL DEFAULT TRUE + weekly_report boolean NOT NULL DEFAULT TRUE, + origin user_origin NULL DEFAULT NULL, + ); diff --git a/scripts/helm/app/chalice.yaml b/scripts/helm/app/chalice.yaml index ece9a511a..afc1b08e0 100644 --- a/scripts/helm/app/chalice.yaml +++ b/scripts/helm/app/chalice.yaml @@ -66,4 +66,9 @@ env: # Enable logging for python app # Ref: https://stackoverflow.com/questions/43969743/logs-in-kubernetes-pod-not-showing-up PYTHONUNBUFFERED: '0' - version_number: '1.0.0' + version_number: '1.1.0' + SAML2_MD_URL: '' + idp_entityId: '' + idp_sso_url: '' + idp_x509cert: '' + idp_sls_url: '' diff --git a/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql b/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql index 321227acf..1a94b917a 100644 --- a/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql +++ b/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql @@ -31,4 +31,8 @@ CREATE INDEX resources_session_id_timestamp_duration_durationgt0NN_img_idx ON ev CREATE INDEX resources_timestamp_session_id_idx ON events.resources (timestamp, session_id); CREATE INDEX errors_project_id_error_id_integration_idx ON public.errors (project_id, error_id) WHERE source != 'js_exception'; CREATE INDEX errors_error_id_timestamp_session_id_idx ON events.errors (error_id, timestamp, session_id); +CREATE TYPE user_origin AS ENUM ('saml'); +ALTER TABLE public.users + ADD COLUMN origin user_origin NULL DEFAULT NULL, + ADD COLUMN internal_id text NULL DEFAULT NULL; COMMIT; \ No newline at end of file