From 3958743b33e4e4f7d24cd43e9dec740be9c674d7 Mon Sep 17 00:00:00 2001 From: Mateusz Mandera Date: Wed, 15 Nov 2023 22:44:24 +0100 Subject: [PATCH] corporate: Add prototype authentication system for self-hosters. This makes it possible for a self-hosted realm administrator to directly access a logged-page on the push notifications bouncer service, enabling billing, support contacts, and other administrator for enterprise customers to be managed without manual setup. --- corporate/urls.py | 22 +++ corporate/views/remote_billing_page.py | 196 ++++++++++++++++++++++++ templates/corporate/remote_billing.html | 22 +++ tools/test-backend | 3 + web/src/popover_menus_data.js | 2 + web/templates/gear_menu_popover.hbs | 8 + zerver/decorator.py | 16 ++ zerver/lib/exceptions.py | 14 ++ zerver/lib/home.py | 8 + zerver/lib/remote_server.py | 23 ++- zerver/lib/test_helpers.py | 3 + zerver/tests/test_home.py | 23 +++ zerver/views/push_notifications.py | 61 +++++++- zilencer/urls.py | 4 + zproject/urls.py | 5 + 15 files changed, 404 insertions(+), 6 deletions(-) create mode 100644 corporate/views/remote_billing_page.py create mode 100644 templates/corporate/remote_billing.html diff --git a/corporate/urls.py b/corporate/urls.py index 310d9d8088..c76bdd0902 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -15,6 +15,13 @@ from corporate.views.portico import ( plans_view, team_view, ) +from corporate.views.remote_billing_page import ( + remote_billing_page_realm, + remote_billing_page_server, + remote_billing_plans_realm, + remote_billing_plans_server, + remote_server_billing_finalize_login, +) from corporate.views.session import ( start_card_update_stripe_session, start_card_update_stripe_session_for_realm_upgrade, @@ -147,3 +154,18 @@ urlpatterns += [ path("api/v1/", include(v1_api_and_json_patterns)), path("json/", include(v1_api_and_json_patterns)), ] + +urlpatterns += [ + path( + "remote-billing-login/", remote_server_billing_finalize_login + ), + # Remote server billling endpoints. + path("realm//plans", remote_billing_plans_realm, name="remote_billing_plans_realm"), + path( + "server//plans", + remote_billing_plans_server, + name="remote_billing_plans_server", + ), + path("realm//billing", remote_billing_page_realm, name="remote_billing_page_realm"), + path("server//", remote_billing_page_server, name="remote_billing_page_server"), +] diff --git a/corporate/views/remote_billing_page.py b/corporate/views/remote_billing_page.py new file mode 100644 index 0000000000..2e4212d8fc --- /dev/null +++ b/corporate/views/remote_billing_page.py @@ -0,0 +1,196 @@ +import logging +from typing import Optional, TypedDict + +from django.conf import settings +from django.core import signing +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.decorators.csrf import csrf_exempt +from pydantic import Json + +from zerver.decorator import self_hosting_management_endpoint +from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError +from zerver.lib.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling +from zerver.lib.response import json_success +from zerver.lib.typed_endpoint import PathOnly, typed_endpoint +from zilencer.models import RemoteRealm, RemoteZulipServer + +billing_logger = logging.getLogger("corporate.stripe") + + +class RemoteBillingIdentityDict(TypedDict): + user_uuid: str + user_email: str + user_full_name: str + remote_server_uuid: str + remote_realm_uuid: str + + +@csrf_exempt +@typed_endpoint +def remote_server_billing_entry( + request: HttpRequest, + remote_server: RemoteZulipServer, + *, + user: Json[UserDataForRemoteBilling], + realm: Json[RealmDataForAnalytics], +) -> HttpResponse: + if not settings.DEVELOPMENT: + return render(request, "404.html", status=404) + + try: + remote_realm = RemoteRealm.objects.get(uuid=realm.uuid, server=remote_server) + except RemoteRealm.DoesNotExist: + # This error will prod the remote server to submit its realm info, which + # should lead to the creation of this missing RemoteRealm registration. + raise MissingRemoteRealmError + + identity_dict = RemoteBillingIdentityDict( + user_email=user.email, + user_uuid=str(user.uuid), + user_full_name=user.full_name, + remote_server_uuid=str(remote_server.uuid), + remote_realm_uuid=str(remote_realm.uuid), + ) + + signed_identity_dict = signing.dumps(identity_dict) + + billing_access_url = ( + f"{settings.EXTERNAL_URI_SCHEME}{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}" + + reverse(remote_server_billing_finalize_login, args=[signed_identity_dict]) + ) + return json_success(request, data={"billing_access_url": billing_access_url}) + + +@self_hosting_management_endpoint +def remote_server_billing_finalize_login( + request: HttpRequest, signed_billing_access_token: str +) -> HttpResponse: + try: + identity_dict: RemoteBillingIdentityDict = signing.loads( + signed_billing_access_token, max_age=settings.SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS + ) + except signing.SignatureExpired: + raise JsonableError(_("Billing access token expired.")) + except signing.BadSignature: + raise JsonableError(_("Invalid billing access token.")) + + remote_realm_uuid = identity_dict["remote_realm_uuid"] + + request.session["remote_billing_identities"] = {} + request.session["remote_billing_identities"][remote_realm_uuid] = identity_dict + + # TODO: Figure out redirects based on whether the realm/server already has a plan + # and should be taken to /billing or doesn't have and should be taken to /plans. + # For now we're only implemented the case where we have the RemoteRealm, and we take + # to /plans. + + return HttpResponseRedirect(reverse("remote_billing_plans_realm", args=(remote_realm_uuid,))) + + +def render_tmp_remote_billing_page( + request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str] +) -> HttpResponse: + authed_uuid = realm_uuid or server_uuid + assert authed_uuid is not None + + identity_dict = None + + identity_dicts = request.session.get("remote_billing_identities") + if identity_dicts is not None: + identity_dict = identity_dicts.get(authed_uuid) + + if identity_dict is None: + raise JsonableError(_("User not authenticated")) + + user_email = identity_dict["user_email"] + user_full_name = identity_dict["user_full_name"] + remote_server_uuid = identity_dict["remote_server_uuid"] + remote_realm_uuid = identity_dict["remote_realm_uuid"] + + try: + remote_server = RemoteZulipServer.objects.get(uuid=remote_server_uuid) + except RemoteZulipServer.DoesNotExist: + raise JsonableError(_("Invalid remote server.")) + + try: + # Checking for the (uuid, server) is sufficient to be secure here, since the server + # is authenticated. the uuid_owner_secret is not needed here, it'll be used for + # for validating transfers of a realm to a different RemoteZulipServer (in the + # export-import process). + remote_realm = RemoteRealm.objects.get(uuid=remote_realm_uuid, server=remote_server) + except RemoteRealm.DoesNotExist: + raise AssertionError( + "The remote realm is missing despite being in the RemoteBillingIdentityDict" + ) + + remote_server_and_realm_info = { + "remote_server_uuid": remote_server_uuid, + "remote_server_hostname": remote_server.hostname, + "remote_server_contact_email": remote_server.contact_email, + "remote_server_plan_type": remote_server.plan_type, + "remote_realm_uuid": remote_realm_uuid, + "remote_realm_host": remote_realm.host, + } + + return render( + request, + "corporate/remote_billing.html", + context={ + "user_email": user_email, + "user_full_name": user_full_name, + "remote_server_and_realm_info": remote_server_and_realm_info, + }, + ) + + +def remote_billing_plans_common( + request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str] +) -> HttpResponse: + """ + Once implemented, this function, shared between remote_billing_plans_realm + and remote_billing_plans_server, will return a Plans page, adjusted depending + on whether the /realm/... or /server/... endpoint is being used + """ + + return render_tmp_remote_billing_page(request, realm_uuid=realm_uuid, server_uuid=server_uuid) + + +@self_hosting_management_endpoint +@typed_endpoint +def remote_billing_plans_realm(request: HttpRequest, *, realm_uuid: PathOnly[str]) -> HttpResponse: + return remote_billing_plans_common(request, realm_uuid=realm_uuid, server_uuid=None) + + +@self_hosting_management_endpoint +@typed_endpoint +def remote_billing_plans_server( + request: HttpRequest, *, server_uuid: PathOnly[str] +) -> HttpResponse: + return remote_billing_plans_common(request, server_uuid=server_uuid, realm_uuid=None) + + +def remote_billing_page_common( + request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str] +) -> HttpResponse: + """ + Once implemented, this function, shared between remote_billing_page_realm + and remote_billing_page_server, will return a Billing page, adjusted depending + on whether the /realm/... or /server/... endpoint is being used + """ + + return render_tmp_remote_billing_page(request, realm_uuid=realm_uuid, server_uuid=server_uuid) + + +@self_hosting_management_endpoint +@typed_endpoint +def remote_billing_page_server(request: HttpRequest, *, server_uuid: PathOnly[str]) -> HttpResponse: + return remote_billing_page_common(request, server_uuid=server_uuid, realm_uuid=None) + + +@self_hosting_management_endpoint +@typed_endpoint +def remote_billing_page_realm(request: HttpRequest, *, realm_uuid: PathOnly[str]) -> HttpResponse: + return remote_billing_page_common(request, realm_uuid=realm_uuid, server_uuid=None) diff --git a/templates/corporate/remote_billing.html b/templates/corporate/remote_billing.html new file mode 100644 index 0000000000..3f4193951f --- /dev/null +++ b/templates/corporate/remote_billing.html @@ -0,0 +1,22 @@ +{% extends "zerver/portico.html" %} +{% set entrypoint = "upgrade" %} + +{% block title %} +{{ _("Billing") }} | Zulip +{% endblock %} + +{% block portico_content %} + +{% endblock %} diff --git a/tools/test-backend b/tools/test-backend index 530eb15f36..9704d0ccd1 100755 --- a/tools/test-backend +++ b/tools/test-backend @@ -54,6 +54,9 @@ not_yet_fully_covered = [ "analytics/views/installation_activity.py", "analytics/views/stats.py", "analytics/views/support.py", + # TODO: This is a work in progress and therefore without + # tests yet. + "corporate/views/remote_billing_page.py", # Major lib files should have 100% coverage "zerver/actions/presence.py", "zerver/lib/addressee.py", diff --git a/web/src/popover_menus_data.js b/web/src/popover_menus_data.js index c6a5b43d45..b2ec8bcd7b 100644 --- a/web/src/popover_menus_data.js +++ b/web/src/popover_menus_data.js @@ -196,6 +196,7 @@ export function get_gear_menu_content_context() { is_owner: page_params.is_owner, is_admin: page_params.is_admin, is_self_hosted: page_params.realm_plan_type === 1, + is_development_environment: page_params.development_environment, is_plan_limited: page_params.realm_plan_type === 2, is_plan_standard, is_plan_standard_sponsored_for_free: page_params.realm_plan_type === 4, @@ -213,6 +214,7 @@ export function get_gear_menu_content_context() { login_link: page_params.development_environment ? "/devlogin/" : "/login/", promote_sponsoring_zulip: page_params.promote_sponsoring_zulip, show_billing: page_params.show_billing, + show_remote_billing: page_params.show_remote_billing, show_plans: page_params.show_plans, show_webathena: page_params.show_webathena, sponsorship_pending: page_params.sponsorship_pending, diff --git a/web/templates/gear_menu_popover.hbs b/web/templates/gear_menu_popover.hbs index 941a4b8318..73496b9057 100644 --- a/web/templates/gear_menu_popover.hbs +++ b/web/templates/gear_menu_popover.hbs @@ -119,6 +119,14 @@ {{/if}} + {{#if (and is_development_environment show_remote_billing) }} + {{! This is only shown in development environment until the UI is ready.}} + + {{/if}} {{#if promote_sponsoring_zulip}}