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.
This commit is contained in:
Mateusz Mandera 2023-11-15 22:44:24 +01:00 committed by Tim Abbott
parent 1ec0d5bd9d
commit 3958743b33
15 changed files with 404 additions and 6 deletions

View File

@ -15,6 +15,13 @@ from corporate.views.portico import (
plans_view, plans_view,
team_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 ( from corporate.views.session import (
start_card_update_stripe_session, start_card_update_stripe_session,
start_card_update_stripe_session_for_realm_upgrade, start_card_update_stripe_session_for_realm_upgrade,
@ -147,3 +154,18 @@ urlpatterns += [
path("api/v1/", include(v1_api_and_json_patterns)), path("api/v1/", include(v1_api_and_json_patterns)),
path("json/", include(v1_api_and_json_patterns)), path("json/", include(v1_api_and_json_patterns)),
] ]
urlpatterns += [
path(
"remote-billing-login/<signed_billing_access_token>", remote_server_billing_finalize_login
),
# Remote server billling endpoints.
path("realm/<realm_uuid>/plans", remote_billing_plans_realm, name="remote_billing_plans_realm"),
path(
"server/<server_uuid>/plans",
remote_billing_plans_server,
name="remote_billing_plans_server",
),
path("realm/<realm_uuid>/billing", remote_billing_page_realm, name="remote_billing_page_realm"),
path("server/<server_uuid>/", remote_billing_page_server, name="remote_billing_page_server"),
]

View File

@ -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)

View File

@ -0,0 +1,22 @@
{% extends "zerver/portico.html" %}
{% set entrypoint = "upgrade" %}
{% block title %}
<title>{{ _("Billing") }} | Zulip</title>
{% endblock %}
{% block portico_content %}
<div id="remote-billing-page" class="register-account flex full-page">
<div class="center-block new-style">
<div class="pitch">
<h1>Your remote user info: </h1>
</div>
<div class="white-box">
Email: {{ user_email }}<br />
Full name: {{ user_full_name }}<br />
Remote server hostname: {{ remote_server_and_realm_info["remote_server_hostname"] }}<br />
Remote realm host: {{ remote_server_and_realm_info.get("remote_realm_host") }}
</div>
</div>
</div>
{% endblock %}

View File

@ -54,6 +54,9 @@ not_yet_fully_covered = [
"analytics/views/installation_activity.py", "analytics/views/installation_activity.py",
"analytics/views/stats.py", "analytics/views/stats.py",
"analytics/views/support.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 # Major lib files should have 100% coverage
"zerver/actions/presence.py", "zerver/actions/presence.py",
"zerver/lib/addressee.py", "zerver/lib/addressee.py",

View File

@ -196,6 +196,7 @@ export function get_gear_menu_content_context() {
is_owner: page_params.is_owner, is_owner: page_params.is_owner,
is_admin: page_params.is_admin, is_admin: page_params.is_admin,
is_self_hosted: page_params.realm_plan_type === 1, 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_limited: page_params.realm_plan_type === 2,
is_plan_standard, is_plan_standard,
is_plan_standard_sponsored_for_free: page_params.realm_plan_type === 4, 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/", login_link: page_params.development_environment ? "/devlogin/" : "/login/",
promote_sponsoring_zulip: page_params.promote_sponsoring_zulip, promote_sponsoring_zulip: page_params.promote_sponsoring_zulip,
show_billing: page_params.show_billing, show_billing: page_params.show_billing,
show_remote_billing: page_params.show_remote_billing,
show_plans: page_params.show_plans, show_plans: page_params.show_plans,
show_webathena: page_params.show_webathena, show_webathena: page_params.show_webathena,
sponsorship_pending: page_params.sponsorship_pending, sponsorship_pending: page_params.sponsorship_pending,

View File

@ -119,6 +119,14 @@
</a> </a>
</li> </li>
{{/if}} {{/if}}
{{#if (and is_development_environment show_remote_billing) }}
{{! This is only shown in development environment until the UI is ready.}}
<li class="link-item navbar-dropdown-menu-inner-list-item">
<a href="/self-hosted-billing/" target="_blank" rel="noopener noreferrer" class="navigate-link-on-enter navbar-dropdown-menu-link">
<i class="navbar-dropdown-icon zulip-icon zulip-icon-credit-card" aria-hidden="true"></i> {{t 'Billing' }}
</a>
</li>
{{/if}}
{{#if promote_sponsoring_zulip}} {{#if promote_sponsoring_zulip}}
<li class="link-item navbar-dropdown-menu-inner-list-item"> <li class="link-item navbar-dropdown-menu-inner-list-item">
<a href="https://zulip.com/help/support-zulip-project" target="_blank" rel="noopener noreferrer" class="navigate-link-on-enter navbar-dropdown-menu-link"> <a href="https://zulip.com/help/support-zulip-project" target="_blank" rel="noopener noreferrer" class="navigate-link-on-enter navbar-dropdown-menu-link">

View File

@ -65,6 +65,7 @@ from zerver.models import UserProfile, get_client, get_user_profile_by_api_key
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http.request import _ImmutableQueryDict from django.http.request import _ImmutableQueryDict
from django.shortcuts import render
webhook_logger = logging.getLogger("zulip.zerver.webhooks") webhook_logger = logging.getLogger("zulip.zerver.webhooks")
webhook_unsupported_events_logger = logging.getLogger("zulip.zerver.webhooks.unsupported") webhook_unsupported_events_logger = logging.getLogger("zulip.zerver.webhooks.unsupported")
@ -74,6 +75,21 @@ ParamT = ParamSpec("ParamT")
ReturnT = TypeVar("ReturnT") ReturnT = TypeVar("ReturnT")
def self_hosting_management_endpoint(
view_func: Callable[Concatenate[HttpRequest, ParamT], HttpResponse]
) -> Callable[Concatenate[HttpRequest, ParamT], HttpResponse]: # nocoverage
@wraps(view_func)
def _wrapped_view_func(
request: HttpRequest, /, *args: ParamT.args, **kwargs: ParamT.kwargs
) -> HttpResponse:
subdomain = get_subdomain(request)
if not settings.DEVELOPMENT or subdomain != settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN:
return render(request, "404.html", status=404)
return view_func(request, *args, **kwargs)
return _wrapped_view_func
def update_user_activity( def update_user_activity(
request: HttpRequest, user_profile: UserProfile, query: Optional[str] request: HttpRequest, user_profile: UserProfile, query: Optional[str]
) -> None: ) -> None:

View File

@ -46,6 +46,7 @@ class ErrorCode(Enum):
REACTION_ALREADY_EXISTS = auto() REACTION_ALREADY_EXISTS = auto()
REACTION_DOES_NOT_EXIST = auto() REACTION_DOES_NOT_EXIST = auto()
SERVER_NOT_READY = auto() SERVER_NOT_READY = auto()
MISSING_REMOTE_REALM = auto()
class JsonableError(Exception): class JsonableError(Exception):
@ -570,3 +571,16 @@ class ApiParamValidationError(JsonableError):
class ServerNotReadyError(JsonableError): class ServerNotReadyError(JsonableError):
code = ErrorCode.SERVER_NOT_READY code = ErrorCode.SERVER_NOT_READY
http_status_code = 500 http_status_code = 500
class MissingRemoteRealmError(JsonableError): # nocoverage
code: ErrorCode = ErrorCode.MISSING_REMOTE_REALM
http_status_code = 403
def __init__(self) -> None:
pass
@staticmethod
@override
def msg_format() -> str:
return _("Organization not registered")

View File

@ -16,6 +16,7 @@ from zerver.lib.i18n import (
get_language_translation_data, get_language_translation_data,
) )
from zerver.lib.narrow_helpers import NarrowTerm from zerver.lib.narrow_helpers import NarrowTerm
from zerver.lib.push_notifications import uses_notification_bouncer
from zerver.lib.realm_description import get_realm_rendered_description from zerver.lib.realm_description import get_realm_rendered_description
from zerver.lib.request import RequestNotes from zerver.lib.request import RequestNotes
from zerver.models import Message, Realm, Stream, UserProfile from zerver.models import Message, Realm, Stream, UserProfile
@ -28,6 +29,7 @@ class BillingInfo:
show_billing: bool show_billing: bool
show_plans: bool show_plans: bool
sponsorship_pending: bool sponsorship_pending: bool
show_remote_billing: bool
@dataclass @dataclass
@ -80,6 +82,10 @@ def get_billing_info(user_profile: Optional[UserProfile]) -> BillingInfo:
show_billing = False show_billing = False
show_plans = False show_plans = False
sponsorship_pending = False sponsorship_pending = False
show_remote_billing = (
user_profile is not None and user_profile.has_billing_access and uses_notification_bouncer()
)
# This query runs on home page load, so we want to avoid # This query runs on home page load, so we want to avoid
# hitting the database if possible. So, we only run it for the user # hitting the database if possible. So, we only run it for the user
# types that can actually see the billing info. # types that can actually see the billing info.
@ -101,6 +107,7 @@ def get_billing_info(user_profile: Optional[UserProfile]) -> BillingInfo:
show_billing=show_billing, show_billing=show_billing,
show_plans=show_plans, show_plans=show_plans,
sponsorship_pending=sponsorship_pending, sponsorship_pending=sponsorship_pending,
show_remote_billing=show_remote_billing,
) )
@ -207,6 +214,7 @@ def build_page_params_for_home_page_load(
two_fa_enabled=two_fa_enabled, two_fa_enabled=two_fa_enabled,
apps_page_url=get_apps_page_url(), apps_page_url=get_apps_page_url(),
show_billing=billing_info.show_billing, show_billing=billing_info.show_billing,
show_remote_billing=billing_info.show_remote_billing,
promote_sponsoring_zulip=promote_sponsoring_zulip_in_realm(realm), promote_sponsoring_zulip=promote_sponsoring_zulip_in_realm(realm),
show_plans=billing_info.show_plans, show_plans=billing_info.show_plans,
sponsorship_pending=billing_info.sponsorship_pending, sponsorship_pending=billing_info.sponsorship_pending,

View File

@ -1,6 +1,6 @@
import logging import logging
import urllib import urllib
from typing import Any, Dict, List, Mapping, Tuple, Union from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
import orjson import orjson
import requests import requests
@ -11,7 +11,7 @@ from pydantic import UUID4, BaseModel, ConfigDict
from analytics.models import InstallationCount, RealmCount from analytics.models import InstallationCount, RealmCount
from version import ZULIP_VERSION from version import ZULIP_VERSION
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError
from zerver.lib.export import floatify_datetime_fields from zerver.lib.export import floatify_datetime_fields
from zerver.lib.outgoing_http import OutgoingSession from zerver.lib.outgoing_http import OutgoingSession
from zerver.models import Realm, RealmAuditLog from zerver.models import Realm, RealmAuditLog
@ -43,6 +43,12 @@ class RealmDataForAnalytics(BaseModel):
uuid_owner_secret: str uuid_owner_secret: str
class UserDataForRemoteBilling(BaseModel):
uuid: UUID4
email: str
full_name: str
def send_to_push_bouncer( def send_to_push_bouncer(
method: str, method: str,
endpoint: str, endpoint: str,
@ -117,6 +123,14 @@ def send_to_push_bouncer(
from zerver.lib.push_notifications import InvalidRemotePushDeviceTokenError from zerver.lib.push_notifications import InvalidRemotePushDeviceTokenError
raise InvalidRemotePushDeviceTokenError raise InvalidRemotePushDeviceTokenError
elif (
endpoint == "server/billing"
and "code" in result_dict
and result_dict["code"] == "MISSING_REMOTE_REALM"
): # nocoverage
# The callers requesting this endpoint want the exception to propagate
# so they can catch it.
raise MissingRemoteRealmError
else: else:
# But most other errors coming from the push bouncer # But most other errors coming from the push bouncer
# server are client errors (e.g. never-registered token) # server are client errors (e.g. never-registered token)
@ -186,8 +200,11 @@ def build_analytics_data(
) )
def get_realms_info_for_push_bouncer() -> List[RealmDataForAnalytics]: def get_realms_info_for_push_bouncer(realm_id: Optional[int] = None) -> List[RealmDataForAnalytics]:
realms = Realm.objects.order_by("id") realms = Realm.objects.order_by("id")
if realm_id is not None: # nocoverage
realms = realms.filter(id=realm_id)
realm_info_list = [ realm_info_list = [
RealmDataForAnalytics( RealmDataForAnalytics(
id=realm.id, id=realm.id,

View File

@ -531,6 +531,9 @@ def write_instrumentation_reports(full_suite: bool, include_webhooks: bool) -> N
"scim/v2/ServiceProviderConfig", "scim/v2/ServiceProviderConfig",
"scim/v2/Groups(?:/(?P<uuid>[^/]+))?", "scim/v2/Groups(?:/(?P<uuid>[^/]+))?",
"scim/v2/Groups/.search", "scim/v2/Groups/.search",
# TODO: This endpoint and the rest of its system are a work in progress,
# we are not testing it yet.
"self-hosted-billing/",
*(webhook.url for webhook in WEBHOOK_INTEGRATIONS if not include_webhooks), *(webhook.url for webhook in WEBHOOK_INTEGRATIONS if not include_webhooks),
} }

View File

@ -212,6 +212,7 @@ class HomeTest(ZulipTestCase):
"settings_send_digest_emails", "settings_send_digest_emails",
"show_billing", "show_billing",
"show_plans", "show_plans",
"show_remote_billing",
"show_webathena", "show_webathena",
"sponsorship_pending", "sponsorship_pending",
"starred_messages", "starred_messages",
@ -364,6 +365,7 @@ class HomeTest(ZulipTestCase):
"server_sentry_dsn", "server_sentry_dsn",
"show_billing", "show_billing",
"show_plans", "show_plans",
"show_remote_billing",
"show_webathena", "show_webathena",
"sponsorship_pending", "sponsorship_pending",
"test_suite", "test_suite",
@ -831,6 +833,7 @@ class HomeTest(ZulipTestCase):
self.assertEqual(page_params["narrow"], [dict(operator="stream", operand=stream_name)]) self.assertEqual(page_params["narrow"], [dict(operator="stream", operand=stream_name)])
self.assertEqual(page_params["max_message_id"], -1) self.assertEqual(page_params["max_message_id"], -1)
@override_settings(PUSH_NOTIFICATION_BOUNCER_URL="https://push.zulip.org.example.com")
def test_get_billing_info(self) -> None: def test_get_billing_info(self) -> None:
user = self.example_user("desdemona") user = self.example_user("desdemona")
user.role = UserProfile.ROLE_REALM_OWNER user.role = UserProfile.ROLE_REALM_OWNER
@ -841,6 +844,7 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertTrue(billing_info.show_remote_billing)
# realm owner, with inactive CustomerPlan and realm plan_type SELF_HOSTED -> show only billing link # realm owner, with inactive CustomerPlan and realm plan_type SELF_HOSTED -> show only billing link
customer = Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_id") customer = Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_id")
@ -857,6 +861,7 @@ class HomeTest(ZulipTestCase):
self.assertTrue(billing_info.show_billing) self.assertTrue(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertTrue(billing_info.show_remote_billing)
# realm owner, with inactive CustomerPlan and realm plan_type LIMITED -> show billing link and plans # realm owner, with inactive CustomerPlan and realm plan_type LIMITED -> show billing link and plans
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_LIMITED, acting_user=None) do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_LIMITED, acting_user=None)
@ -865,6 +870,7 @@ class HomeTest(ZulipTestCase):
self.assertTrue(billing_info.show_billing) self.assertTrue(billing_info.show_billing)
self.assertTrue(billing_info.show_plans) self.assertTrue(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertTrue(billing_info.show_remote_billing)
# Always false without CORPORATE_ENABLED # Always false without CORPORATE_ENABLED
with self.settings(CORPORATE_ENABLED=False): with self.settings(CORPORATE_ENABLED=False):
@ -872,6 +878,8 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
# show_remote_billing is independent of CORPORATE_ENABLED
self.assertTrue(billing_info.show_remote_billing)
# Always false without a UserProfile # Always false without a UserProfile
with self.settings(CORPORATE_ENABLED=True): with self.settings(CORPORATE_ENABLED=True):
@ -879,6 +887,7 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertFalse(billing_info.show_remote_billing)
# realm admin, with CustomerPlan and realm plan_type LIMITED -> don't show any links # realm admin, with CustomerPlan and realm plan_type LIMITED -> don't show any links
# Only billing admin and realm owner have access to billing. # Only billing admin and realm owner have access to billing.
@ -889,6 +898,7 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertFalse(billing_info.show_remote_billing)
# billing admin, with CustomerPlan and realm plan_type STANDARD -> show only billing link # billing admin, with CustomerPlan and realm plan_type STANDARD -> show only billing link
user.role = UserProfile.ROLE_MEMBER user.role = UserProfile.ROLE_MEMBER
@ -900,6 +910,7 @@ class HomeTest(ZulipTestCase):
self.assertTrue(billing_info.show_billing) self.assertTrue(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertTrue(billing_info.show_remote_billing)
# billing admin, with CustomerPlan and realm plan_type PLUS -> show only billing link # billing admin, with CustomerPlan and realm plan_type PLUS -> show only billing link
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_PLUS, acting_user=None) do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_PLUS, acting_user=None)
@ -909,6 +920,7 @@ class HomeTest(ZulipTestCase):
self.assertTrue(billing_info.show_billing) self.assertTrue(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertTrue(billing_info.show_remote_billing)
# member, with CustomerPlan and realm plan_type STANDARD -> neither billing link or plans # member, with CustomerPlan and realm plan_type STANDARD -> neither billing link or plans
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_STANDARD, acting_user=None) do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_STANDARD, acting_user=None)
@ -919,6 +931,7 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertFalse(billing_info.show_remote_billing)
# guest, with CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans # guest, with CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans
user.role = UserProfile.ROLE_GUEST user.role = UserProfile.ROLE_GUEST
@ -929,6 +942,7 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertFalse(billing_info.show_remote_billing)
# billing admin, but no CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans # billing admin, but no CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans
user.role = UserProfile.ROLE_MEMBER user.role = UserProfile.ROLE_MEMBER
@ -940,6 +954,7 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertTrue(billing_info.show_remote_billing)
# billing admin, with sponsorship pending and realm plan_type SELF_HOSTED -> show only sponsorship pending link # billing admin, with sponsorship pending and realm plan_type SELF_HOSTED -> show only sponsorship pending link
customer.sponsorship_pending = True customer.sponsorship_pending = True
@ -949,6 +964,7 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertTrue(billing_info.sponsorship_pending) self.assertTrue(billing_info.sponsorship_pending)
self.assertTrue(billing_info.show_remote_billing)
# billing admin, no customer object and realm plan_type SELF_HOSTED -> no links # billing admin, no customer object and realm plan_type SELF_HOSTED -> no links
customer.delete() customer.delete()
@ -957,6 +973,13 @@ class HomeTest(ZulipTestCase):
self.assertFalse(billing_info.show_billing) self.assertFalse(billing_info.show_billing)
self.assertFalse(billing_info.show_plans) self.assertFalse(billing_info.show_plans)
self.assertFalse(billing_info.sponsorship_pending) self.assertFalse(billing_info.sponsorship_pending)
self.assertTrue(billing_info.show_remote_billing)
# If the server doesn't have the push bouncer configured,
# don't show remote billing.
with self.settings(PUSH_NOTIFICATION_BOUNCER_URL=None):
billing_info = get_billing_info(user)
self.assertFalse(billing_info.show_remote_billing)
def test_promote_sponsoring_zulip_in_realm(self) -> None: def test_promote_sponsoring_zulip_in_realm(self) -> None:
realm = get_realm("zulip") realm = get_realm("zulip")

View File

@ -1,17 +1,30 @@
import re import re
from typing import Optional from typing import Optional
from django.http import HttpRequest, HttpResponse from django.conf import settings
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from zerver.decorator import human_users_only from zerver.decorator import human_users_only, zulip_login_required
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import (
JsonableError,
MissingRemoteRealmError,
OrganizationOwnerRequiredError,
)
from zerver.lib.push_notifications import ( from zerver.lib.push_notifications import (
InvalidPushDeviceTokenError, InvalidPushDeviceTokenError,
add_push_device_token, add_push_device_token,
b64_to_hex, b64_to_hex,
remove_push_device_token, remove_push_device_token,
send_test_push_notification, send_test_push_notification,
uses_notification_bouncer,
)
from zerver.lib.remote_server import (
UserDataForRemoteBilling,
get_realms_info_for_push_bouncer,
send_realms_only_to_push_bouncer,
send_to_push_bouncer,
) )
from zerver.lib.request import REQ, has_request_variables from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
@ -101,3 +114,45 @@ def send_test_push_notification_api(
send_test_push_notification(user_profile, devices) send_test_push_notification(user_profile, devices)
return json_success(request) return json_success(request)
@zulip_login_required
def self_hosting_auth_redirect(request: HttpRequest) -> HttpResponse: # nocoverage
if not settings.DEVELOPMENT or not uses_notification_bouncer():
return render(request, "404.html", status=404)
user = request.user
assert user.is_authenticated
assert isinstance(user, UserProfile)
if not user.has_billing_access:
# We may want to replace this with an html error page at some point,
# but this endpoint shouldn't be accessible via the UI to an unauthorized
# user - and they need to directly enter the URL in their browser. So a json
# error may be sufficient.
raise OrganizationOwnerRequiredError
realm_info = get_realms_info_for_push_bouncer(user.realm_id)[0]
user_info = UserDataForRemoteBilling(
uuid=user.uuid,
email=user.delivery_email,
full_name=user.full_name,
)
post_data = {
"user": user_info.model_dump_json(),
"realm": realm_info.model_dump_json(),
}
try:
result = send_to_push_bouncer("POST", "server/billing", post_data)
except MissingRemoteRealmError:
# Upload realm info and re-try. It should work now.
send_realms_only_to_push_bouncer()
result = send_to_push_bouncer("POST", "server/billing", post_data)
if result["result"] != "success":
raise JsonableError(_("Error returned by the bouncer: {result}").format(result=result))
redirect_url = result["billing_access_url"]
assert isinstance(redirect_url, str)
return HttpResponseRedirect(redirect_url)

View File

@ -3,6 +3,7 @@ from typing import Any
from django.conf.urls import include from django.conf.urls import include
from django.urls import path from django.urls import path
from corporate.views.remote_billing_page import remote_server_billing_entry
from zilencer.auth import remote_server_path from zilencer.auth import remote_server_path
from zilencer.views import ( from zilencer.views import (
deactivate_remote_server, deactivate_remote_server,
@ -33,6 +34,9 @@ push_bouncer_patterns = [
remote_server_path("remotes/server/analytics/status", GET=remote_server_check_analytics), remote_server_path("remotes/server/analytics/status", GET=remote_server_check_analytics),
] ]
billing_patterns = [remote_server_path("remotes/server/billing", POST=remote_server_billing_entry)]
urlpatterns = [ urlpatterns = [
path("api/v1/", include(push_bouncer_patterns)), path("api/v1/", include(push_bouncer_patterns)),
path("api/v1/", include(billing_patterns)),
] ]

View File

@ -92,6 +92,7 @@ from zerver.views.push_notifications import (
add_apns_device_token, add_apns_device_token,
remove_android_reg_id, remove_android_reg_id,
remove_apns_device_token, remove_apns_device_token,
self_hosting_auth_redirect,
send_test_push_notification_api, send_test_push_notification_api,
) )
from zerver.views.reactions import add_reaction, remove_reaction from zerver.views.reactions import add_reaction, remove_reaction
@ -820,6 +821,10 @@ urls += [
path("policies/<slug:article>", policy_documentation_view), path("policies/<slug:article>", policy_documentation_view),
] ]
urls += [
path("self-hosted-billing/", self_hosting_auth_redirect, name="self_hosting_auth_redirect"),
]
if not settings.CORPORATE_ENABLED: # nocoverage if not settings.CORPORATE_ENABLED: # nocoverage
# This conditional behavior cannot be tested directly, since # This conditional behavior cannot be tested directly, since
# urls.py is not readily reloaded in Django tests. See the block # urls.py is not readily reloaded in Django tests. See the block