Skip to content
Merged
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
1 change: 1 addition & 0 deletions posthog/api/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,7 @@
)
return instance

def _build_integration(self, validated_data: Any) -> Any:

Check warning on line 733 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`_build_integration` has cyclomatic complexity 62 (warn >10)

Check warning on line 733 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`_build_integration` has cyclomatic complexity 62 (warn >10)
request = self.context["request"]
team_id = self.context["team_id"]

Expand Down Expand Up @@ -1394,7 +1394,7 @@
return [GitHubRepositoryRefreshThrottle(), *super().get_throttles()]
return super().get_throttles()

def perform_destroy(self, instance: Integration) -> None:

Check warning on line 1397 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`perform_destroy` has cyclomatic complexity 21 (warn >10)

Check warning on line 1397 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`perform_destroy` has cyclomatic complexity 21 (warn >10)
flows_using_integration = get_active_workflows_using_integration(
team_id=instance.team_id, integration_id=instance.id
)
Expand Down Expand Up @@ -1624,7 +1624,7 @@
responses={200: SlackChannelsResponseSerializer},
)
@action(methods=["GET"], detail=True, url_path="channels")
def channels(self, request: Request, *args: Any, **kwargs: Any) -> Response:

Check warning on line 1627 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`channels` has cyclomatic complexity 11 (warn >10)

Check warning on line 1627 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`channels` has cyclomatic complexity 11 (warn >10)
instance = self.get_object()
if instance.kind not in SLACK_INTEGRATION_KINDS:
raise ValidationError("channels endpoint is only supported for Slack integrations")
Expand Down Expand Up @@ -1719,7 +1719,7 @@
responses={200: SlackUsersResponseSerializer},
)
@action(methods=["GET"], detail=True, url_path="users")
def users(self, request: Request, *args: Any, **kwargs: Any) -> Response:

Check warning on line 1722 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`users` has cyclomatic complexity 18 (warn >10)

Check warning on line 1722 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`users` has cyclomatic complexity 18 (warn >10)
instance = self.get_object()
if instance.kind not in SLACK_INTEGRATION_KINDS:
raise ValidationError("users endpoint is only supported for Slack integrations")
Expand Down Expand Up @@ -2021,7 +2021,7 @@
def anthropic_managed_agent_vaults(self, request: Request, *args: Any, **kwargs: Any) -> Response:
return self._anthropic_managed_list_response(request, resource="vaults")

def _anthropic_managed_list_response(self, request: Request, *, resource: str) -> Response:

Check warning on line 2024 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`_anthropic_managed_list_response` has cyclomatic complexity 11 (warn >10)

Check warning on line 2024 in posthog/api/integration.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`_anthropic_managed_list_response` has cyclomatic complexity 11 (warn >10)
from anthropic import ( # noqa: PLC0415
APIConnectionError,
APIStatusError,
Expand Down Expand Up @@ -2488,6 +2488,7 @@
host=resolved.host,
provider_endpoint=provider_endpoint,
redirect_uri=redirect_uri,
group_ids=resolved.group_ids,
)
except DomainConnectSigningKeyMissing as e:
capture_exception(
Expand Down
37 changes: 34 additions & 3 deletions posthog/domain_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from dns.rdtypes.txtbase import TXTBase

from posthog.schema import DomainConnectProviderName

Expand Down Expand Up @@ -102,6 +103,7 @@ def build_sync_apply_url(
redirect_uri: str | None = None,
private_key: RSAPrivateKey | None = None,
key_id: str | None = None,
group_ids: tuple[str, ...] = (),
) -> str:
"""Build a Domain Connect synchronous apply URL.

Expand All @@ -112,6 +114,8 @@ def build_sync_apply_url(
prefixes it to the host of each template record, which scopes the template to a
subdomain. Templates that set hostRequired must get one.

If group_ids is set, the provider applies only those template groups instead of all of them.

If private_key is provided, the query string is signed with RS256 and
sig= / key= parameters are appended (required by providers like Cloudflare).
"""
Expand All @@ -121,6 +125,8 @@ def build_sync_apply_url(
if host:
params["host"] = host
params.update(variables)
if group_ids:
params["groupId"] = ",".join(group_ids)
if redirect_uri:
params["redirect_uri"] = redirect_uri

Expand Down Expand Up @@ -221,6 +227,11 @@ class DomainConnectContext:
host: str
service_id: str
variables: dict[str, str]
group_ids: tuple[str, ...] = ()


# Cloudflare ignores TXT conflict matching and would publish a second, invalidating DMARC record.
EMAIL_TEMPLATE_GROUPS_WITHOUT_DMARC: tuple[str, ...] = ("verification", "dkim", "spf", "mailfrom")


def resolve_email_context(integration_id: int, team_id: int) -> DomainConnectContext:
Expand Down Expand Up @@ -268,13 +279,17 @@ def resolve_email_context(integration_id: int, team_id: int) -> DomainConnectCon
"mailFromSub": mail_from_subdomain,
"sesRegion": ses_region,
}
group_ids = (
() if _dmarc_absence_confirmed(instance.config.get("domain", "")) else EMAIL_TEMPLATE_GROUPS_WITHOUT_DMARC
)
# The template variables are bare SES tokens and a subdomain label, so they stay
# the same when the records move from the full sender domain to root plus host.
return DomainConnectContext(
root_domain=domain_parts.root_domain,
host=domain_parts.host,
service_id=service_id,
variables=variables,
group_ids=group_ids,
)


Expand Down Expand Up @@ -307,6 +322,7 @@ def generate_apply_url(
host: str | None = None,
provider_endpoint: str | None = None,
redirect_uri: str | None = None,
group_ids: tuple[str, ...] = (),
) -> str:
"""Generate a Domain Connect apply URL, either via auto-discovery or a specific provider.

Expand Down Expand Up @@ -349,9 +365,25 @@ def generate_apply_url(
redirect_uri=redirect_uri,
private_key=signing_key,
key_id=key_id,
group_ids=group_ids,
)


def _txt_value(rdata: TXTBase) -> str:
return "".join(s.decode("utf-8") if isinstance(s, bytes) else s for s in rdata.strings).strip()


def _dmarc_absence_confirmed(domain: str) -> bool:
try:
answers = dns.resolver.resolve(f"_dmarc.{domain}", "TXT", lifetime=5)
return not any(_txt_value(rdata).lower().startswith("v=dmarc1") for rdata in answers)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
return True
except Exception:
logger.warning("DMARC lookup failed for %s", domain, exc_info=True)
return False


def _lookup_domain_connect_endpoint(domain: str) -> str | None:
"""DNS TXT lookup for _domainconnect.{domain}.

Expand All @@ -360,10 +392,9 @@ def _lookup_domain_connect_endpoint(domain: str) -> str | None:
try:
answers = dns.resolver.resolve(f"_domainconnect.{domain}", "TXT")
for rdata in answers:
# TXT records come as a list of strings; join them
txt_value = "".join(s.decode("utf-8") if isinstance(s, bytes) else s for s in rdata.strings)
txt_value = _txt_value(rdata)
if txt_value:
return txt_value.strip()
return txt_value
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers, dns.resolver.Timeout):
pass
except Exception:
Expand Down
83 changes: 78 additions & 5 deletions posthog/test/test_domain_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import json
import base64
from pathlib import Path
from urllib.parse import unquote

from posthog.test.base import BaseTest
from unittest.mock import MagicMock, patch

from django.core.cache import cache

import dns.resolver
from cryptography.hazmat.primitives import hashes as crypto_hashes
from cryptography.hazmat.primitives.asymmetric import (
padding as asym_padding,
Expand All @@ -17,6 +19,7 @@

from posthog.domain_connect import (
DOMAIN_CONNECT_PROVIDERS,
EMAIL_TEMPLATE_GROUPS_WITHOUT_DMARC,
DomainConnectSigningKeyMissing,
build_sync_apply_url,
discover_domain_connect,
Expand Down Expand Up @@ -138,10 +141,19 @@ def test_url_with_signing(self) -> None:
host="ph",
private_key=private_key,
key_id="_dcpubkeyv1",
group_ids=("dkim", "spf"),
)

self.assertIn("sig=", url)
self.assertIn("key=_dcpubkeyv1", url)
signed_query, signature_params = url.split("?", 1)[1].split("&sig=", 1)
self.assertIn("groupId=dkim%2Cspf", signed_query)
self.assertIn("key=_dcpubkeyv1", signature_params)
signature = unquote(signature_params.split("&key=", 1)[0])
private_key.public_key().verify(
base64.b64decode(signature),
signed_query.encode("utf-8"),
asym_padding.PKCS1v15(),
crypto_hashes.SHA256(),
)

def test_url_without_signing_key_has_no_sig(self) -> None:
url = build_sync_apply_url(
Expand Down Expand Up @@ -335,8 +347,51 @@ def test_proxy_resolver_variables_match_template(

@parameterized.expand(
[
("subdomain sender", "posthog.com.email-verification-us.json", "US", "news.example.com", "news"),
("root domain sender", "posthog.com.email-verification-eu.json", "EU", "example.com", ""),
(
"subdomain sender without a dmarc record",
"posthog.com.email-verification-us.json",
"US",
"news.example.com",
"news",
dns.resolver.NXDOMAIN(),
True,
),
(
"root sender without a dmarc record",
"posthog.com.email-verification-eu.json",
"EU",
"example.com",
"",
dns.resolver.NoAnswer(),
True,
),
(
"sender with an existing dmarc record",
"posthog.com.email-verification-us.json",
"US",
"news.example.com",
"news",
[MagicMock(strings=[b"v=DMARC1; p=reject;"])],
False,
),
(
"sender whose dmarc lookup times out",
"posthog.com.email-verification-eu.json",
"EU",
"example.com",
"",
dns.resolver.Timeout(),
False,
),
(
"sender whose dmarc record is not utf-8",
"posthog.com.email-verification-us.json",
"US",
"news.example.com",
"news",
[MagicMock(strings=[b"\xff"])],
False,
),
]
)
@patch("posthog.models.integration.EmailIntegration")
Expand All @@ -348,6 +403,8 @@ def test_email_resolver_variables_match_template(
region: str,
sender_domain: str,
expected_host: str,
dmarc_lookup: Exception | list[MagicMock],
applies_dmarc: bool,
mock_integration_cls: MagicMock,
mock_email_cls: MagicMock,
) -> None:
Expand Down Expand Up @@ -375,13 +432,29 @@ def test_email_resolver_variables_match_template(
}
mock_email_cls.return_value = mock_email

with self.settings(CLOUD_DEPLOYMENT=region, SES_REGION="us-east-1"):
with (
self.settings(CLOUD_DEPLOYMENT=region, SES_REGION="us-east-1"),
patch("posthog.domain_connect.dns.resolver.resolve") as mock_resolve,
):
if isinstance(dmarc_lookup, Exception):
mock_resolve.side_effect = dmarc_lookup
else:
mock_resolve.return_value = dmarc_lookup
resolved = resolve_email_context(1, 1)

mock_resolve.assert_called_once_with(f"_dmarc.{sender_domain}", "TXT", lifetime=5)

self.assertEqual(set(resolved.variables.keys()), expected_vars)
self.assertEqual(resolved.service_id, template["serviceId"])
self.assertEqual(resolved.root_domain, "example.com")
self.assertEqual(resolved.host, expected_host)
self.assertEqual(resolved.variables["verifyToken"], "verify-token-123")
self.assertEqual(resolved.variables["dkim1"], "aaa")
self.assertEqual(resolved.variables["mailFromSub"], "feedback")

template_groups = {record["groupId"] for record in template["records"]}
if applies_dmarc:
self.assertEqual(resolved.group_ids, ())
else:
self.assertEqual(resolved.group_ids, EMAIL_TEMPLATE_GROUPS_WITHOUT_DMARC)
self.assertEqual(template_groups - set(resolved.group_ids), {"dmarc"})
Loading