diff --git a/.github/scripts/check-idor-model-coverage.py b/.github/scripts/check-idor-model-coverage.py index aa7d0b87aa9c..a025b761894d 100644 --- a/.github/scripts/check-idor-model-coverage.py +++ b/.github/scripts/check-idor-model-coverage.py @@ -355,6 +355,7 @@ def get_scoped_models() -> tuple[dict[str, set[str]], set[str], set[str], set[st "RoleMembership", "LinkedIdentityProviderConfig", "IdJagIdentity", + "SandboxSenderOrganizationBlock", # --- User-scoped (cross-tenant by design) --- "NotificationViewed", "SCIMProvisionedUser", diff --git a/.semgrep/rules/security/idor-team-scoped-models.yaml b/.semgrep/rules/security/idor-team-scoped-models.yaml index 212b0afc496e..0502e7516442 100644 --- a/.semgrep/rules/security/idor-team-scoped-models.yaml +++ b/.semgrep/rules/security/idor-team-scoped-models.yaml @@ -1376,6 +1376,7 @@ rules: |Role |RoleExternalReference |RoleMembership + |SandboxSenderOrganizationBlock )$ - focus-metavariable: $SINK pattern-sanitizers: @@ -1459,6 +1460,7 @@ rules: |Role |RoleExternalReference |RoleMembership + |SandboxSenderOrganizationBlock )$ - pattern-not: $M.objects. ... .$METHOD2(..., organization=$O, ...) - pattern-not: $M.objects. ... .$METHOD2(..., organization_id=$O, ...) diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 865e62487730..6db1b6190e27 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -28,6 +28,8 @@ Only workflows that send email are subject to the tiers; SMS, push, and webhook The `workflows-sandbox-sender` flag enables a PostHog-managed email integration per project through `POST /api/projects/:project_id/integrations/email_sandbox_sender/`. The endpoint requires `integration:write` and project membership. Repeated requests keep the same integration and refresh its display name from the organization name. +Staff turn the sandbox sender off for one organization by adding a `SandboxSenderOrganizationBlock` row in the Django admin, and lift the block by deleting the row. +While the block exists, the endpoint returns 404 for the organization's projects. Existing sandbox senders stay, and the email worker does not read the block. Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN`, `SES_SANDBOX_FROM_ADDRESS`, and `SES_SANDBOX_TENANT_NAME` in Django; any empty value leaves the endpoint unavailable. The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Django and the worker. The address the UI shows is then the address the worker sends from. Workflow email steps and test sends offer it as a sender. diff --git a/products/workflows/backend/admin/__init__.py b/products/workflows/backend/admin/__init__.py index 9e90819008f7..dae51551b034 100644 --- a/products/workflows/backend/admin/__init__.py +++ b/products/workflows/backend/admin/__init__.py @@ -4,5 +4,6 @@ hog_flow_admin, hog_flow_batch_job_admin, hog_flow_template_admin, + sandbox_sender_organization_block_admin, team_workflows_config_admin, ) diff --git a/products/workflows/backend/admin/sandbox_sender_organization_block_admin.py b/products/workflows/backend/admin/sandbox_sender_organization_block_admin.py new file mode 100644 index 000000000000..6c5e0792b146 --- /dev/null +++ b/products/workflows/backend/admin/sandbox_sender_organization_block_admin.py @@ -0,0 +1,31 @@ +from typing import cast + +from django.contrib import admin +from django.forms import ModelForm +from django.http import HttpRequest + +from posthog.models.user import User + +from products.workflows.backend.models import SandboxSenderOrganizationBlock + + +@admin.register(SandboxSenderOrganizationBlock) +class SandboxSenderOrganizationBlockAdmin(admin.ModelAdmin): + list_display = ("organization", "reason", "created_by", "created_at") + fields = ("organization", "reason", "created_by", "created_at") + readonly_fields = ("created_by", "created_at") + raw_id_fields = ("organization",) + search_fields = ("organization__id", "organization__name", "reason") + list_select_related = ("organization", "created_by") + ordering = ("-created_at",) + + def save_model( + self, + request: HttpRequest, + obj: SandboxSenderOrganizationBlock, + form: ModelForm | None, + change: bool, + ) -> None: + if not change: + obj.created_by = cast(User, request.user) + super().save_model(request, obj, form, change) diff --git a/products/workflows/backend/migrations/0040_sandbox_sender_organization_block.py b/products/workflows/backend/migrations/0040_sandbox_sender_organization_block.py new file mode 100644 index 000000000000..aa63af505d18 --- /dev/null +++ b/products/workflows/backend/migrations/0040_sandbox_sender_organization_block.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.18 on 2026-10-26 06:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "1411_personalapikey_expires_at"), + ("workflows", "0039_sandbox_sender_tenant_state"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SandboxSenderOrganizationBlock", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("reason", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.OneToOneField( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="posthog.organization", + ), + ), + ], + ), + ] diff --git a/products/workflows/backend/migrations/max_migration.txt b/products/workflows/backend/migrations/max_migration.txt index c3daea1fde11..c2673e4f68d4 100644 --- a/products/workflows/backend/migrations/max_migration.txt +++ b/products/workflows/backend/migrations/max_migration.txt @@ -1 +1 @@ -0039_sandbox_sender_tenant_state +0040_sandbox_sender_organization_block diff --git a/products/workflows/backend/models/__init__.py b/products/workflows/backend/models/__init__.py index fdf755e421f6..1b310b9e4de3 100644 --- a/products/workflows/backend/models/__init__.py +++ b/products/workflows/backend/models/__init__.py @@ -4,6 +4,7 @@ from .hog_flow_optimization import HogFlowOptimization from .hog_flow_revision import HogFlowRevision from .hog_flow_schedule.hog_flow_schedule import HogFlowSchedule +from .sandbox_sender_organization_block import SandboxSenderOrganizationBlock from .sandbox_sender_tenant_state import SandboxSenderTenantState from .team_workflows_config import TeamWorkflowsConfig from .workflow_idea import WorkflowIdea @@ -16,6 +17,7 @@ "HogFlowRevision", "HogFlowSchedule", "HogFlowTemplate", + "SandboxSenderOrganizationBlock", "SandboxSenderTenantState", "TeamWorkflowsConfig", "WorkflowIdea", diff --git a/products/workflows/backend/models/sandbox_sender_organization_block.py b/products/workflows/backend/models/sandbox_sender_organization_block.py new file mode 100644 index 000000000000..6e579d32d197 --- /dev/null +++ b/products/workflows/backend/models/sandbox_sender_organization_block.py @@ -0,0 +1,15 @@ +from django.db import models + + +class SandboxSenderOrganizationBlock(models.Model): + organization = models.OneToOneField( + "posthog.Organization", on_delete=models.CASCADE, db_constraint=False, related_name="+" + ) + reason = models.TextField() + created_by = models.ForeignKey( + "posthog.User", on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False, related_name="+" + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return self.organization.name diff --git a/products/workflows/backend/services/sandbox_sender.py b/products/workflows/backend/services/sandbox_sender.py index 2da94cd73d1f..2828edb454e5 100644 --- a/products/workflows/backend/services/sandbox_sender.py +++ b/products/workflows/backend/services/sandbox_sender.py @@ -8,6 +8,7 @@ from posthog.plugins.plugin_server_api import reload_integrations_on_workers from products.workflows.backend.facade.contracts import SandboxEmailSender, SandboxSenderUnavailable +from products.workflows.backend.models.sandbox_sender_organization_block import SandboxSenderOrganizationBlock logger = structlog.get_logger(__name__) @@ -54,9 +55,13 @@ def _sandbox_sender_enabled(team: Team) -> bool: return False +def _organization_blocked(team: Team) -> bool: + return SandboxSenderOrganizationBlock.objects.filter(organization_id=team.organization_id).exists() + + def ensure_sandbox_email_sender(team_id: int) -> SandboxEmailSender: team = Team.objects.select_related("organization").get(id=team_id) - if not _sandbox_sender_configured() or not _sandbox_sender_enabled(team): + if not _sandbox_sender_configured() or not _sandbox_sender_enabled(team) or _organization_blocked(team): raise SandboxSenderUnavailable() config = _sandbox_sender_config(team.organization.name) diff --git a/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py b/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py new file mode 100644 index 000000000000..46946a4e78c4 --- /dev/null +++ b/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py @@ -0,0 +1,36 @@ +from posthog.test.base import BaseTest + +from django.test import Client, override_settings + +from products.workflows.backend.models import SandboxSenderOrganizationBlock + +ADMIN_URL = "/admin/workflows/sandboxsenderorganizationblock/" + + +@override_settings(STORAGES={"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}}) +class TestSandboxSenderOrganizationBlockAdmin(BaseTest): + def setUp(self) -> None: + super().setUp() + self.user.is_staff = True + self.user.save() + self.organization.name = "Blocked example organization" + self.organization.save() + self.client = Client() + self.client.force_login(self.user) + + def test_staff_add_a_block_with_its_author_and_delete_it(self) -> None: + added = self.client.post( + f"{ADMIN_URL}add/", data={"organization": str(self.organization.id), "reason": "Abuse report"} + ) + + assert added.status_code == 302, added.content + block = SandboxSenderOrganizationBlock.objects.get(organization=self.organization) + assert (block.reason, block.created_by) == ("Abuse report", self.user) + + confirmation = self.client.get(f"{ADMIN_URL}{block.pk}/delete/") + assert self.organization.name in confirmation.content.decode() + + deleted = self.client.post(f"{ADMIN_URL}{block.pk}/delete/", data={"post": "yes"}) + + assert deleted.status_code == 302, deleted.content + assert not SandboxSenderOrganizationBlock.objects.filter(organization=self.organization).exists() diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py index 45fd51429a48..b70307dd0c95 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -10,10 +10,12 @@ from rest_framework import status from posthog.models.integration import Integration -from posthog.models.organization import OrganizationMembership +from posthog.models.organization import Organization, OrganizationMembership from posthog.models.personal_api_key import PersonalAPIKey from posthog.models.utils import hash_key_value +from products.workflows.backend.models import SandboxSenderOrganizationBlock + if TYPE_CHECKING: from rest_framework.response import _MonkeyPatchedResponse @@ -114,6 +116,25 @@ def test_ensure_is_not_found_while_the_sandbox_sender_is_unavailable( assert not self._sandbox_rows().exists() assert self._provisioned_events() == [] + def test_ensure_is_not_found_while_the_organization_is_blocked_and_keeps_existing_senders(self) -> None: + other_organization = Organization.objects.create(name="Other organization") + SandboxSenderOrganizationBlock.objects.create(organization=other_organization, reason="Abuse report") + block = SandboxSenderOrganizationBlock.objects.create(organization=self.organization, reason="Abuse report") + + blocked = self._ensure() + + assert blocked.status_code == status.HTTP_404_NOT_FOUND, blocked.json() + assert not self._sandbox_rows().exists() + assert self._provisioned_events() == [] + block.delete() + unblocked = self._ensure() + assert unblocked.status_code == status.HTTP_200_OK, unblocked.json() + assert self._sandbox_rows().count() == 1 + SandboxSenderOrganizationBlock.objects.create(organization=self.organization, reason="Abuse report") + reblocked = self._ensure() + assert reblocked.status_code == status.HTTP_404_NOT_FOUND, reblocked.json() + assert self._sandbox_rows().count() == 1 + @parameterized.expand( [ ("read scope", "integration:read", status.HTTP_403_FORBIDDEN),