Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ See [Client Types](#client-types) section for detailed explanation.
}
```

## Domain Connect access

The default-off `workflows-email-domain-agent-setup` flag enables OAuth tokens and Personal API Keys with `integration:write` to request an email Domain Connect apply URL. Scoped credentials cannot request reverse proxy apply URLs. Existing session calls for email and reverse proxy setup do not require this flag.

## Available Scopes

OAuth supports all the same scopes as Personal API Keys. Each scope has a `read` and/or `write` action (e.g., `experiment:read`, `experiment:write`).
Expand Down
190 changes: 123 additions & 67 deletions posthog/api/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import time
from collections.abc import Callable, Iterable
from typing import Any, NoReturn, Protocol, cast
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
from uuid import uuid4

from django.conf import settings
Expand All @@ -15,6 +15,7 @@
from django.shortcuts import redirect
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.utils.http import url_has_allowed_host_and_scheme

import structlog
from django_filters.rest_framework import DjangoFilterBackend
Expand All @@ -24,7 +25,7 @@
from prometheus_client import Counter
from redis.exceptions import RedisError
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.exceptions import APIException, PermissionDenied, Throttled, ValidationError
from rest_framework.exceptions import APIException, NotFound, PermissionDenied, Throttled, ValidationError
from rest_framework.permissions import BasePermission, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -45,16 +46,34 @@
github_app_install_url,
is_valid_github_installation_id,
)
from posthog.api.integration_domain_serializers import (
DomainConnectApplyUrlRequestSerializer,
DomainConnectApplyUrlResponseSerializer,
DomainConnectCheckQuerySerializer,
DomainConnectCheckResponseSerializer,
EmailDomainVerificationSerializer,
EmailSenderUpdateRequestSerializer,
NativeEmailIntegrationSerializer,
)
from posthog.api.mixins import ValidatedRequest, validated_request
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import UserBasicSerializer
from posthog.api.utils import action
from posthog.auth import SessionAuthentication
from posthog.domain_connect import discover_domain_connect, extract_root_domain_and_host, get_available_providers
from posthog.domain_connect import (
DomainConnectSigningKeyMissing,
discover_domain_connect,
extract_root_domain_and_host,
generate_apply_url,
get_available_providers,
resolve_email_context,
resolve_proxy_context,
)
from posthog.egress.github.transport import GitHubRateLimitError
from posthog.event_usage import report_user_action
from posthog.exceptions_capture import capture_exception
from posthog.helpers.fuzzy_search import fuzzy_filter
from posthog.models import OrganizationMembership, User
from posthog.models import OrganizationMembership, ProxyRecord, Team, User
from posthog.models.github_integration_base import GitHubIntegrationBase
from posthog.models.integration import (
ANTHROPIC_DEFAULT_INTEGRATION_ID_PREFIX,
Expand Down Expand Up @@ -117,7 +136,9 @@
TeamMemberLightManagementPermission,
TeamMemberStrictManagementPermission,
TimeSensitiveActionPermission,
get_authenticator_scopes,
)
from posthog.ph_client import feature_enabled_or_false
from posthog.rate_limit import GitHubRepositoryRefreshThrottle
from posthog.tasks.email import send_integration_access_request
from posthog.utils import absolute_uri, is_relative_url
Expand All @@ -144,6 +165,8 @@

GITHUB_REPOSITORY_NAME_RE = re.compile(r"[A-Za-z0-9_.\-]+")

EMAIL_DOMAIN_AGENT_SETUP_FLAG = "workflows-email-domain-agent-setup"


class SlackIntegrationInactiveError(APIException):
# Actionable 4xx (reconnect Slack) rather than a raw 500, so the channel picker can render
Expand Down Expand Up @@ -303,16 +326,6 @@ def _github_disconnect_blocked_message(live_runs: InProgressGithubRunsDTO, team_
)


class NativeEmailIntegrationSerializer(serializers.Serializer):
email = serializers.EmailField()
name = serializers.CharField()
provider = serializers.ChoiceField(choices=["ses", "maildev"] if settings.DEBUG else ["ses"])
mail_from_subdomain = serializers.CharField(required=False, allow_blank=True)

def validate_email(self, value: str) -> str:
return value.lower()


class GitHubRepoSerializer(serializers.Serializer):
id = serializers.IntegerField(help_text="GitHub repository numeric identifier.")
name = serializers.CharField(help_text="Repository short name (without the owner prefix).")
Expand Down Expand Up @@ -1448,6 +1461,28 @@ def has_object_permission(self, request: Request, view: APIView, obj: object) ->
return True


def email_domain_agent_setup_enabled(team: Team, user: User) -> bool:
return feature_enabled_or_false(
EMAIL_DOMAIN_AGENT_SETUP_FLAG,
str(user.distinct_id),
groups={"organization": str(team.organization_id), "project": str(team.uuid)},
group_properties={"organization": {"id": str(team.organization_id)}, "project": {"id": str(team.uuid)}},
send_feature_flag_events=False,
)


class EmailDomainAgentSetupPermission(BasePermission):
message = "This action does not support personal API key access"
token_actions = frozenset({"domain_connect_check", "domain_connect_apply_url", "email_update", "email_verify"})

def has_permission(self, request: Request, view: APIView) -> bool:
if getattr(view, "action", None) not in self.token_actions:
return True
if get_authenticator_scopes(request.successful_authenticator) is None:
return True
return email_domain_agent_setup_enabled(cast("IntegrationViewSet", view).team, cast(User, request.user))


@extend_schema(extensions={"x-product": "integrations"})
class IntegrationViewSet(
TeamAndOrgViewSetMixin,
Expand Down Expand Up @@ -1476,6 +1511,7 @@ class IntegrationViewSet(
"anthropic_managed_agents",
"anthropic_managed_agent_environments",
"anthropic_managed_agent_vaults",
"domain_connect_check",
]
scope_object_write_actions = [
"create",
Expand All @@ -1489,8 +1525,15 @@ class IntegrationViewSet(
"github_oauth_authorize",
# Side-effecting POST (emails admins) — a read-only token must not be able to trigger it.
"request_access",
"email_update",
"email_verify",
"domain_connect_apply_url",
]
permission_classes = [
IntegrationManagementPermission,
PersonalConnectionRecentAuthPermission,
EmailDomainAgentSetupPermission,
]
permission_classes = [IntegrationManagementPermission, PersonalConnectionRecentAuthPermission]
# LimitOffsetPagination needs a total order, or Postgres can return a row on neither side of a
# page boundary. Clients page this list to find one kind, so a dropped row reads as
# "not configured". Order oldest-first: several clients take the first row of a kind as their
Expand Down Expand Up @@ -2631,35 +2674,49 @@ def gitlab_members(self, request: Request, *args: Any, **kwargs: Any) -> Respons
search = query_serializer.validated_data["search"]
return assignees_response(lambda: GitLabIntegration(instance).list_assignees(search))

@validated_request(
responses={200: EmailDomainVerificationSerializer},
summary="Verify an email sender's domain",
description=(
"Ask the email provider to check the sending domain's DNS records and return each record with its status. "
"When every record is verified, all senders on the domain in this project become able to send."
),
)
@action(methods=["POST"], detail=True, url_path="email/verify")
def email_verify(self, request, **kwargs):
email = EmailIntegration(self.get_object())
verification_result = email.verify()
def email_verify(self, request: ValidatedRequest, **kwargs: Any) -> Response:
verification_result = self._get_email_integration().verify()
return Response(verification_result)

@extend_schema(responses={200: IntegrationSerializer})
@validated_request(
EmailSenderUpdateRequestSerializer,
responses={200: IntegrationSerializer},
summary="Update an email sender",
)
@action(methods=["PATCH"], detail=True, url_path="email")
def email_update(self, request, **kwargs) -> Response:
instance = self.get_object()
config = request.data.get("config", {})

serializer = NativeEmailIntegrationSerializer(data=config)
serializer.is_valid(raise_exception=True)

email = EmailIntegration(instance)
email.update_native_integration(serializer.validated_data, instance.team_id)

def email_update(self, request: ValidatedRequest, **kwargs: Any) -> Response:
email = self._get_email_integration()
config = request.validated_data["config"]
if email_domain_agent_setup_enabled(self.team, cast(User, request.user)):
email.reject_address_change(config["email"])
email.update_native_integration(config, email.integration.team_id)
return Response(IntegrationSerializer(email.integration).data)

def _get_email_integration(self) -> EmailIntegration:
instance = self.get_object()
if instance.kind != "email":
raise ValidationError("This endpoint is only supported for email integrations")
return EmailIntegration(instance)

@validated_request(
query_serializer=DomainConnectCheckQuerySerializer,
responses={200: DomainConnectCheckResponseSerializer},
summary="Check Domain Connect support for a domain",
)
# nosemgrep: api-path-underscore -- shipped public API path, a rename breaks clients
@action(methods=["GET"], detail=False, url_path="domain-connect/check")
def domain_connect_check(self, request: Request, *args: Any, **kwargs: Any) -> Response:
domain = request.query_params.get("domain", "")
if not domain:
raise ValidationError("domain query parameter is required")

def domain_connect_check(self, request: ValidatedRequest, *args: Any, **kwargs: Any) -> Response:
# Extract root domain so subdomains (e.g. ph.example.com) resolve correctly
domain_parts = extract_root_domain_and_host(domain)
domain_parts = extract_root_domain_and_host(request.validated_query_data["domain"])
result = discover_domain_connect(domain_parts.root_domain)
return Response(
{
Expand All @@ -2669,58 +2726,50 @@ def domain_connect_check(self, request: Request, *args: Any, **kwargs: Any) -> R
}
)

@validated_request(
DomainConnectApplyUrlRequestSerializer,
responses={200: DomainConnectApplyUrlResponseSerializer},
summary="Generate a Domain Connect apply URL",
description=(
"Build the signed URL that sends a person to their DNS host to approve the records for an email "
"sending domain or a reverse proxy domain."
),
)
# nosemgrep: api-path-underscore -- shipped public API path, a rename breaks clients
@action(methods=["POST"], detail=False, url_path="domain-connect/apply-url")
def domain_connect_apply_url(self, request: Request, *args: Any, **kwargs: Any) -> Response:
"""Unified endpoint for generating Domain Connect apply URLs.

Accepts a context ("email" or "proxy") and the relevant resource ID.
The backend resolves the domain, template variables, and service ID
based on context, then builds the signed apply URL.
"""
from posthog.domain_connect import (
DOMAIN_CONNECT_PROVIDERS,
DomainConnectSigningKeyMissing,
generate_apply_url,
resolve_email_context,
resolve_proxy_context,
)

context = request.data.get("context")
redirect_uri = request.data.get("redirect_uri")
provider_endpoint = request.data.get("provider_endpoint")

if provider_endpoint and provider_endpoint not in DOMAIN_CONNECT_PROVIDERS:
raise ValidationError("Unsupported provider endpoint")
def domain_connect_apply_url(self, request: ValidatedRequest, *args: Any, **kwargs: Any) -> Response:
data = request.validated_data
context = data["context"]
if context != "email" and get_authenticator_scopes(request.successful_authenticator) is not None:
raise PermissionDenied("Scoped credentials only support email Domain Connect.")
redirect_uri = self._redirect_back_to_posthog(data.get("redirect_uri"))
provider_endpoint = data.get("provider_endpoint")

if context == "email":
integration_id = request.data.get("integration_id")
if not integration_id:
raise ValidationError("integration_id is required for email context")
integration_id = data["integration_id"]
try:
resolved = resolve_email_context(integration_id, self.team_id)
except Integration.DoesNotExist:
raise NotFound("No email sender with this integration_id exists in this project.")
except ValueError as e:
capture_exception(e, {"integration_id": integration_id, "team_id": self.team_id, "context": context})
raise ValidationError(
"Validation error resolving email context. Please try again later or contact support."
)

elif context == "proxy":
proxy_record_id = request.data.get("proxy_record_id")
if not proxy_record_id:
raise ValidationError("proxy_record_id is required for proxy context")
else:
proxy_record_id = data["proxy_record_id"]
organization = self.organization
try:
resolved = resolve_proxy_context(proxy_record_id, str(organization.id))
resolved = resolve_proxy_context(str(proxy_record_id), str(organization.id))
except ProxyRecord.DoesNotExist:
raise NotFound("No reverse proxy record with this proxy_record_id exists in this organization.")
except ValueError as e:
capture_exception(
e, {"proxy_record_id": proxy_record_id, "organization_id": organization.id, "context": context}
)
raise ValidationError(
"Validation error resolving proxy context. Please try again later or contact support."
)
else:
raise ValidationError("context must be 'email' or 'proxy'")

try:
url = generate_apply_url(
Expand Down Expand Up @@ -2756,6 +2805,13 @@ def domain_connect_apply_url(self, request: Request, *args: Any, **kwargs: Any)

return Response({"url": url})

@staticmethod
def _redirect_back_to_posthog(redirect_uri: str | None) -> str | None:
posthog_host = urlsplit(settings.SITE_URL).netloc
if redirect_uri and not url_has_allowed_host_and_scheme(redirect_uri, allowed_hosts={posthog_host}):
raise ValidationError("redirect_uri must point back to PostHog.")
return redirect_uri

# Defined last: a method named `list` shadows the builtin for the annotations of every method
# declared after it in this class body.
def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
Expand Down
Loading
Loading