Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
746fe27
fix(subscriptions): auto-disable when the owner left or lost access
a-lider Oct 26, 2026
8b68ec9
fix(alerts): shorten owner disable reasons and clarify the owner check
a-lider Oct 26, 2026
b5c0aa5
fix(subscriptions): send the reason in the delivery, then disable
a-lider Oct 26, 2026
a02ae6b
fix(alerts): record owner disables as invalid_configuration
a-lider Oct 26, 2026
68bd3ef
fix(alerts): move the owner check into the shared insight evaluator
a-lider Oct 26, 2026
552d975
chore(subscriptions): make the test user a member of the temporal tea…
a-lider Oct 28, 2026
7f8ddfb
fix(subscriptions): count a deleted owner and lost project access as …
a-lider Oct 28, 2026
895d515
fix(subscriptions): deliver a partly denied dashboard, disable even w…
a-lider Oct 29, 2026
620a1bb
fix(subscriptions): stop AI subscriptions whose owner can no longer r…
a-lider Oct 29, 2026
4517b08
Merge remote-tracking branch 'origin/master' into alex/auto-disable-r…
a-lider Oct 29, 2026
18bfe21
fix(subscriptions): stop when the owner can no longer view the dashbo…
a-lider Oct 29, 2026
47cd10f
refactor(subscriptions): one owner check for background queries
a-lider Oct 29, 2026
b56f00e
fix(subscriptions): a member who lost the project lost access, not left
a-lider Oct 29, 2026
150add7
chore(exports): read dashboard_id in the owner test
a-lider Oct 29, 2026
9008fb5
fix(subscriptions): check project access through the facade
a-lider Oct 29, 2026
3f9c518
refactor(subscriptions): raise inside the object access condition
a-lider Oct 29, 2026
42c2a39
fix(subscriptions): read has_project_access as a property, keep trans…
a-lider Oct 29, 2026
d365dab
refactor(subscriptions): drop the disable closure in deliver_subscrip…
a-lider Oct 29, 2026
d1b0665
chore(subscriptions): stub the owner check in database-free delivery …
a-lider Oct 29, 2026
ac7b51b
fix(subscriptions): keep a transient send failure from disabling the …
a-lider Nov 2, 2026
bb87e9d
fix(alerts): check the owner on the per-series evaluation path too
a-lider Nov 2, 2026
3c17c0d
fix(subscriptions): treat an ownerless subscription export as a delet…
a-lider Nov 2, 2026
29e3273
Merge remote-tracking branch 'origin/master' into alex/auto-disable-r…
a-lider Nov 2, 2026
1b4c820
chore(alerts): give the hourly bucket test's alert an owner
a-lider Nov 3, 2026
0c4e8dc
chore(subscriptions): expect the direct exporter call to re-raise the…
a-lider Nov 3, 2026
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
55 changes: 30 additions & 25 deletions posthog/temporal/alerts/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from posthog.email import is_email_available
from posthog.errors import CH_TRANSIENT_ERRORS, CHQueryErrorQueryWasCancelled
from posthog.exceptions_capture import capture_exception
from posthog.query_creator_access import creator_access_revoked, report_creator_access_revoked
from posthog.query_creator_access import creator_access_revoked
from posthog.schema_migrations.upgrade_manager import upgrade_insight
from posthog.sync import database_sync_to_async
from posthog.tasks.alerts.investigation_notifications import run_investigation_notification_safety_net
Expand Down Expand Up @@ -279,11 +279,23 @@

_SLOT_LOST_ERROR_TYPE = "EvaluationSlotLost"

CREATOR_LEFT_DISABLE_REASON = (
"The owner of this alert is no longer in the organization. Create a new alert to keep monitoring this insight."
)
CREATOR_LOST_ACCESS_DISABLE_REASON = (
"The owner of this alert no longer has access to some of the data in this insight. "
"Ask a project admin for access, or create a new alert."
)


class _EvaluationStopped(Exception):
"""Raised in the evaluation thread once its attempt's cancellation began, so it records nothing."""


class _CreatorLeftOrganization(Exception):
pass


async def _hold_evaluation_slot_before_running(alert_id: str, *, lease_seconds: float) -> float:
"""Hold the slot for evaluate_alert, waiting while there is no room or Redis is unreachable.

Expand Down Expand Up @@ -472,11 +484,11 @@


@temporalio.activity.defn
async def prepare_alert(inputs: PrepareAlertActivityInputs) -> PrepareAlertResult:

Check warning on line 487 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

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

lint:complexity

`prepare_alert` has cyclomatic complexity 15 (warn >10)

Check warning on line 487 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

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

`prepare_alert` has cyclomatic complexity 15 (warn >10)
"""Load the alert, validate its config, and decide whether to evaluate."""

@database_sync_to_async(thread_sensitive=False)
def _prepare() -> PrepareAlertResult:

Check warning on line 491 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

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

lint:complexity

`_prepare` has cyclomatic complexity 13 (warn >10)

Check warning on line 491 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

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

`_prepare` has cyclomatic complexity 13 (warn >10)
try:
alert = AlertConfiguration.objects.select_related("insight", "team", "team__organization", "threshold").get(
id=inputs.alert_id
Expand Down Expand Up @@ -630,7 +642,7 @@


@temporalio.activity.defn
async def evaluate_alert(inputs: EvaluateAlertActivityInputs) -> EvaluateAlertResult:

Check warning on line 645 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

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

lint:complexity

`evaluate_alert` has cyclomatic complexity 33 (warn >10)

Check warning on line 645 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

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

`evaluate_alert` has cyclomatic complexity 33 (warn >10)
"""Run the insight ClickHouse query, apply the state machine, persist an AlertCheck row."""
info = temporalio.activity.info()
evaluation_id = f"{info.workflow_run_id}:{info.activity_id}"
Expand All @@ -647,7 +659,7 @@
if stopping.is_set():
raise _EvaluationStopped()

def _evaluate(alert: AlertConfiguration) -> EvaluateAlertResult:

Check warning on line 662 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

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

lint:complexity

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

Check warning on line 662 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

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

`_evaluate` has cyclomatic complexity 21 (warn >10)
_stop_if_cancelled()
evaluated_alert = alert
evaluated_fingerprint = _evaluation_fingerprint(alert)
Expand All @@ -668,9 +680,18 @@
breaches: list[str] | None = None
error: dict | None = None
invalid_configuration: str | None = None
disable_error_code = "invalid_configuration"
alert_evaluation_result = None

try:
# The query runs as the owner, and a query that reads no restricted table would still succeed.
# An alert with no recorded owner is left alone, and the platform delivers platform-owned alerts.
if (
alert.created_by is not None
and not alert.platform_owned
and creator_access_revoked(alert.created_by, alert.team)
):
raise _CreatorLeftOrganization()
alert_evaluation_result = check_alert_for_insight(alert, evaluation_id=evaluation_id)
breaches = alert_evaluation_result.breaches
if is_llm_detector_config(alert.detector_config):
Expand Down Expand Up @@ -700,29 +721,13 @@
# existing path instead of capturing it as an exception, which would pollute error
# tracking with a config problem that recurs on every check until fixed.
invalid_configuration = str(err)
except TableAccessDeniedError as err:
logger.exception("Alert failed to evaluate", alert_id=alert.id, exc_info=err)
# A revoked creator's access-denied error is a known limitation - report it as an event
# rather than capturing it, which would recur on every check until the alert is fixed.
if creator_access_revoked(alert.created_by, alert.team):
report_creator_access_revoked(
user=alert.created_by,
team=alert.team,
source="alert",
error=err,
properties={"alert_id": str(alert.id), "insight_id": alert.insight_id},
)
error = {"message": str(err)}
else:
capture_exception(
err,
additional_properties={
"alert_configuration_id": str(alert.id),
"insight_id": alert.insight_id,
"team_id": alert.team_id,
},
)
error = {"message": str(err), "traceback": traceback.format_exc()}
except _CreatorLeftOrganization:
invalid_configuration = CREATOR_LEFT_DISABLE_REASON
disable_error_code = "creator_left_organization"
except TableAccessDeniedError:
# The owner is still a member, so the denial is their access, not a bug to capture.
invalid_configuration = CREATOR_LOST_ACCESS_DISABLE_REASON
disable_error_code = "creator_lost_access"
except Exception as err:
logger.exception("Alert failed to evaluate", alert_id=alert.id, exc_info=err)
capture_exception(
Expand Down Expand Up @@ -755,7 +760,7 @@
alert = current_alert
if invalid_configuration is not None:
alert_check = disable_invalid_alert(
alert, invalid_configuration, notify_subscribers=False, error_code="invalid_configuration"
alert, invalid_configuration, notify_subscribers=False, error_code=disable_error_code
Comment thread
a-lider marked this conversation as resolved.
Resolved
)
return EvaluateAlertResult(
alert_check_id=str(alert_check.id),
Expand Down
39 changes: 38 additions & 1 deletion posthog/temporal/tests/test_alerts_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
TrendsQuery,
)

from posthog.hogql.errors import TableAccessDeniedError

from posthog.clickhouse.query_tagging import get_query_tags
from posthog.constants import AvailableFeature
from posthog.errors import CHQueryErrorQueryWasCancelled
Expand All @@ -38,7 +40,7 @@
ClickHouseClusterMemoryLimitExceeded,
ClickHouseQueryMemoryLimitExceeded,
)
from posthog.models import Team, User
from posthog.models import OrganizationMembership, Team, User
from posthog.redis import get_client
from posthog.slo.types import SloOperation, SloOutcome
from posthog.tasks.alerts.utils import (
Expand Down Expand Up @@ -1263,6 +1265,41 @@ async def test_evaluate_auto_disables_and_skips_error_tracking_on_configuration_
await sync_to_async(check.refresh_from_db)()
assert check.targets_notified

@pytest.mark.parametrize(
"owner_change, expected_code",
[("left_organization", "creator_left_organization"), ("lost_table_access", "creator_lost_access")],
)
async def test_evaluate_auto_disables_when_the_owner_can_no_longer_run_the_query(
self, alert_with_user, aorganization, owner_change, expected_code
) -> None:
owner = await sync_to_async(lambda: alert_with_user.subscribed_users.get())()
alert_with_user.created_by = owner
await sync_to_async(alert_with_user.save)(update_fields=["created_by"])
if owner_change == "left_organization":
await sync_to_async(
OrganizationMembership.objects.filter(organization_id=aorganization.id, user=owner).delete
)()

with (
patch(
"posthog.temporal.alerts.activities.check_alert_for_insight",
side_effect=TableAccessDeniedError("revenue"),
) as mock_check,
patch("posthog.temporal.alerts.activities.capture_exception") as mock_capture,
):
result = await ActivityEnvironment().run(
evaluate_alert, EvaluateAlertActivityInputs(alert_id=str(alert_with_user.id))
)

assert result.new_state == AlertState.ERRORED
mock_capture.assert_not_called()
# A departed owner's alert must stop even when its query would still run.
assert mock_check.called is (owner_change == "lost_table_access")
check = await sync_to_async(AlertCheck.objects.get)(pk=result.alert_check_id)
assert check.error is not None and check.error["code"] == expected_code
refreshed = await sync_to_async(AlertConfiguration.objects.get)(pk=alert_with_user.pk)
assert refreshed.enabled is False

# Transient CH errors bubble up so Temporal's retry policy handles them.
# Capacity errors (codes 202/439) surface as ClickHouseAtCapacity, so that's what we simulate.
# A server-wide or per-user memory limit is the same kind of cluster pressure: recording it as
Expand Down
70 changes: 66 additions & 4 deletions products/subscriptions/backend/logic/auto_disable.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import uuid
from collections.abc import Iterable
from typing import NamedTuple
from urllib.parse import urlparse

import structlog

from posthog.hogql.errors import TableAccessDeniedError

from posthog.email import EmailMessage
from posthog.exceptions_capture import capture_exception
from posthog.models import User
from posthog.models import OrganizationMembership, User
from posthog.query_creator_access import creator_access_revoked

from products.access_control.backend.facade.contracts import UserNotOrganizationMemberError
from products.access_control.backend.facade.user_access_control import UserAccessControlError
from products.notifications.backend.facade.api import (
NotificationData,
NotificationType,
Expand Down Expand Up @@ -58,6 +64,22 @@ class DisableReason(NamedTuple):
"Add files:write to a custom Slack app, or reconnect PostHog's Slack app, then try again."
),
)
CREATOR_LEFT_DISABLE_REASON = DisableReason(
key="creator_left_organization",
description="The owner of this subscription is no longer in the organization",
user_message=(
"Cannot re-enable {target_type} subscription: its owner is no longer in the organization. "
"Create a new subscription to keep receiving this report."
),
)
CREATOR_LOST_ACCESS_DISABLE_REASON = DisableReason(
key="creator_lost_access",
description="The owner of this subscription no longer has access to some of its data",
user_message=(
"Cannot re-enable {target_type} subscription until its owner has access to all of its data again. "
"Ask a project admin for access, or create a new subscription."
),
)
AI_PROMPT_INVALID_DISABLE_REASON = DisableReason(
key="ai_prompt_invalid",
description="AI subscription prompt or creator is invalid",
Expand All @@ -83,6 +105,28 @@ def get_subscription_disable_reason(target_type: str | None, integration_id: int
return None


def get_creator_disable_reason(subscription: Subscription) -> DisableReason | None:
"""Disable reason when the owner can no longer run the subscription's queries. Deliveries run as the owner.

A subscription with no recorded owner is left alone, because nobody is known to have left.
"""
if subscription.created_by is not None and creator_access_revoked(subscription.created_by, subscription.team):
return CREATOR_LEFT_DISABLE_REASON
return None


# Export failures that mean the owner lost access, not that the export broke.
_ACCESS_DENIED_EXCEPTION_TYPES = frozenset(
{TableAccessDeniedError.__name__, UserAccessControlError.__name__, UserNotOrganizationMemberError.__name__}
)


def get_asset_access_disable_reason(exception_types: Iterable[str | None]) -> DisableReason | None:
if any(exception_type in _ACCESS_DENIED_EXCEPTION_TYPES for exception_type in exception_types):
return CREATOR_LOST_ACCESS_DISABLE_REASON
return None


def target_type_label(target_type: str | None) -> str:
if not target_type:
return ""
Expand Down Expand Up @@ -110,6 +154,24 @@ def _get_notification_creator(subscription: Subscription) -> User | None:
return creator


def _disabled_notification_emails(subscription: Subscription, reason: DisableReason) -> list[str]:
creator = _get_notification_creator(subscription)
if creator is not None:
return [creator.email] if creator.email else []
if reason != CREATOR_LEFT_DISABLE_REASON:
return []
# The owner is gone, so the admins are the only people who can replace the subscription.
return list(
OrganizationMembership.objects.filter(
organization_id=subscription.team.organization_id,
level__gte=OrganizationMembership.Level.ADMIN,
user__is_active=True,
)
.exclude(user__email="")
.values_list("user__email", flat=True)
)


def disable_invalid_subscription(subscription: Subscription, reason: DisableReason) -> None:
# Compare-and-swap so only one racing caller sends the disabled-notification
# email (UUID4 campaign keys mean MessagingRecord can't dedup the duplicate).
Expand Down Expand Up @@ -149,10 +211,10 @@ def disable_invalid_subscription(subscription: Subscription, reason: DisableReas
exc_info=True,
)

creator = _get_notification_creator(subscription)
if creator and creator.email:
emails = _disabled_notification_emails(subscription, reason)
if emails:
try:
send_notifications_for_disabled_subscription(subscription, reason, [creator.email])
send_notifications_for_disabled_subscription(subscription, reason, emails)
except Exception as e:
# Disabling is the durable side effect; email is best-effort. If the email
# fails (SMTP outage, ImproperlyConfigured on self-hosted, Customer.io 5xx)
Expand Down
13 changes: 11 additions & 2 deletions products/subscriptions/backend/temporal/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from products.subscriptions.backend.logic.auto_disable import (
UNSUPPORTED_TARGET_DISABLE_REASON,
disable_invalid_subscription,
get_asset_access_disable_reason,
get_creator_disable_reason,
get_subscription_disable_reason,
)
from products.subscriptions.backend.logic.email_subscriptions import send_email_subscription_report
Expand Down Expand Up @@ -224,7 +226,7 @@ def get_subscriptions() -> list[DueSubscription]:
async def validate_subscription_for_delivery(subscription_id: int) -> DeliveryAbort | None:
"""Returns abort info when delivery should not proceed; None to continue."""
subscription = await database_sync_to_async(
Subscription.objects.select_related("created_by", "integration").get,
Subscription.objects.select_related("created_by", "integration", "team").get,
thread_sensitive=False,
)(pk=subscription_id)

Expand All @@ -234,7 +236,9 @@ async def validate_subscription_for_delivery(subscription_id: int) -> DeliveryAb
await LOGGER.ainfo("validate_subscription.already_disabled_skipping", subscription_id=subscription_id)
return DeliveryAbort()

reason = get_subscription_disable_reason(subscription.target_type, subscription.integration_id)
reason = get_subscription_disable_reason(
subscription.target_type, subscription.integration_id
) or await database_sync_to_async(get_creator_disable_reason, thread_sensitive=False)(subscription)
if reason is None:
return None

Expand Down Expand Up @@ -506,6 +510,11 @@ async def _deliver_insight_dashboard_subscription(
_capture_delivery_failed_event(subscription, Exception(NO_ASSETS_REASON))
return DeliverSubscriptionResult(recipient_results=recipient_results)

# Disable rather than deliver a report with some tiles quietly missing.
access_reason = get_asset_access_disable_reason(asset.exception_type for asset in assets)
if access_reason is not None:
return await auto_disable_and_return(subscription, access_reason, recipient_results)

if subscription.target_type == Subscription.SubscriptionTarget.EMAIL:

async def _send_email(email: str) -> None:
Expand Down
23 changes: 17 additions & 6 deletions products/subscriptions/backend/tests/logic/test_auto_disable.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@

from parameterized import parameterized

from posthog.models import User
from posthog.models import OrganizationMembership, User

from products.subscriptions.backend.logic.auto_disable import (
AI_PROMPT_INVALID_DISABLE_REASON,
CREATOR_LEFT_DISABLE_REASON,
SLACK_DISCONNECTED_DISABLE_REASON,
disable_invalid_subscription,
send_notifications_for_disabled_subscription,
Expand Down Expand Up @@ -155,11 +156,16 @@ def test_no_email_when_creator_unable_to_receive(self, _label, use_creator_with_

@parameterized.expand(
[
("former_creator", False),
("deactivated_creator", True),
("former_creator", False, SLACK_DISCONNECTED_DISABLE_REASON),
("deactivated_creator", True, SLACK_DISCONNECTED_DISABLE_REASON),
("former_creator_left_org", False, CREATOR_LEFT_DISABLE_REASON),
("deactivated_creator_left_org", True, CREATOR_LEFT_DISABLE_REASON),
]
)
def test_does_not_notify_creator_without_access(self, _label, deactivate_member):
def test_does_not_notify_creator_without_access(self, _label, deactivate_member, reason):
admin = User.objects.create_and_join(
self.organization, "admin@example.com", None, level=OrganizationMembership.Level.ADMIN
)
if deactivate_member:
creator = self.user
creator.is_active = False
Expand All @@ -176,10 +182,15 @@ def test_does_not_notify_creator_without_access(self, _label, deactivate_member)
"products.subscriptions.backend.logic.auto_disable.send_notifications_for_disabled_subscription"
) as send_mock,
):
disable_invalid_subscription(sub, SLACK_DISCONNECTED_DISABLE_REASON)
disable_invalid_subscription(sub, reason)

create_notification_mock.assert_not_called()
send_mock.assert_not_called()
if reason == CREATOR_LEFT_DISABLE_REASON:
emails = send_mock.call_args.args[2]
assert admin.email in emails
assert creator.email not in emails
else:
send_mock.assert_not_called()

def test_send_notifications_uses_unique_campaign_key_per_call(self):
sub = self._make_subscription()
Expand Down
Loading
Loading