Skip to content
Draft
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
3 changes: 2 additions & 1 deletion src/sentry/features/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from sentry.features.handler import FeatureHandler
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.users.models.user import User


Expand Down Expand Up @@ -321,7 +322,7 @@ def batch_has(
feature_names: Sequence[str],
actor: User | RpcUser | AnonymousUser | None = None,
projects: Sequence[Project] | None = None,
organization: Organization | None = None,
organization: RpcOrganization | Organization | None = None,
) -> dict[str, dict[str, bool | None]] | None:
"""
Determine if multiple features are enabled. Unhandled flags will not be in
Expand Down
85 changes: 84 additions & 1 deletion src/sentry/integrations/slack/requests/event.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any
from typing import Any, NamedTuple

from sentry.constants import ObjectStatus
from sentry.integrations.messaging.metrics import SeerSlackHaltReason
from sentry.integrations.services.integration import integration_service
from sentry.integrations.slack.requests.base import SlackDMRequest, SlackRequestError
from sentry.integrations.slack.unfurl.handlers import match_link
from sentry.integrations.slack.unfurl.types import LinkType
from sentry.integrations.slack.utils.constants import SlackScope
from sentry.integrations.types import IntegrationProviderSlug
from sentry.models.organization import OrganizationStatus
from sentry.organizations.services.organization.service import organization_service
from sentry.seer.entrypoints.slack.entrypoint import SlackExplorerEntrypoint
from sentry.seer.entrypoints.slack.messaging import send_identity_link_prompt
from sentry.silo.base import all_silo_function

COMMANDS = ["link", "unlink", "link team", "unlink team"]
SLACK_PROVIDERS = [IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING]


def has_discover_links(links: list[str]) -> bool:
Expand All @@ -23,6 +33,11 @@
return data.get("type", "") == "url_verification"


class SeerResolutionResult(NamedTuple):
organization_id: int | None
error_reason: SeerSlackHaltReason | None


class SlackEventRequest(SlackDMRequest):
"""
An Event request sent from Slack.
Expand Down Expand Up @@ -55,6 +70,74 @@
"""We need to call this before validation."""
return is_event_challenge(self.request.data)

@property
def is_seer_explorer_request(self) -> bool:
return (
self.type == "app_mention"
or self.type == "assistant_thread_started"
or (self.dm_data.get("type") == "message" and self.has_assistant_scope)
)

@all_silo_function
def resolve_seer_organization(self) -> SeerResolutionResult:
"""
Resolve and validate an organization/user for a Seer Slack event.

If the initiating user is not linked, we will reply with a prompt to link their identity.

Then we search for an active, organization with Seer Explorer access. If the user does not
belong to any matched organization, their request will be dropped.

Note: There is a limitation here of only grabbing the first organization belonging to the user
with access to Seer. If a Slack installation corresponds to multiple organizations with Seer
access, this will not work as expected. This will be revisited.
"""
identity_user = self.get_identity_user()
if not identity_user:
send_identity_link_prompt(
integration=self.integration,
slack_user_id=self.user_id,
channel_id=self.channel_id,
thread_ts=self.thread_ts or None,
is_welcome_message=self.is_assistant_thread_event,
)
return SeerResolutionResult(
organization_id=None, error_reason=SeerSlackHaltReason.IDENTITY_NOT_LINKED
)

ois = integration_service.get_organization_integrations(
integration_id=self.integration.id,

Check failure on line 109 in src/sentry/integrations/slack/requests/event.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

RuntimeError when resolve_seer_organization accesses self.integration without validation

The new `resolve_seer_organization()` method accesses `self.integration` at lines 98 and 109, but `self.integration` raises `RuntimeError` when `_integration` is None. The `_integration` field is only populated by `_get_context()` which is called from `validate()`. The middleware parser (`_maybe_get_response_from_event_request` in slack.py:297-306) creates a `SlackEventRequest` and calls `resolve_seer_organization()` without first calling `validate()`, causing a RuntimeError to be raised.
Comment on lines +98 to +109
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RuntimeError when resolve_seer_organization accesses self.integration without validation

The new resolve_seer_organization() method accesses self.integration at lines 98 and 109, but self.integration raises RuntimeError when _integration is None. The _integration field is only populated by _get_context() which is called from validate(). The middleware parser (_maybe_get_response_from_event_request in slack.py:297-306) creates a SlackEventRequest and calls resolve_seer_organization() without first calling validate(), causing a RuntimeError to be raised.

Also found at 1 additional location
  • src/sentry/middleware/integrations/parsers/slack.py:303-303

Identified by Warden sentry-backend-bugs · RA8-UWS

status=ObjectStatus.ACTIVE,
providers=SLACK_PROVIDERS,
)
if not ois:
return SeerResolutionResult(
organization_id=None, error_reason=SeerSlackHaltReason.NO_VALID_INTEGRATION
)

for oi in ois:
organization_id = oi.organization_id
ctx = organization_service.get_organization_by_id(
id=oi.organization_id, user_id=identity_user.id
)
if ctx is None:
continue

if ctx.organization.status != OrganizationStatus.ACTIVE:
continue

if not SlackExplorerEntrypoint.has_access(ctx.organization):
continue

if ctx.member is None:
continue

return SeerResolutionResult(organization_id=organization_id, error_reason=None)

return SeerResolutionResult(
organization_id=None, error_reason=SeerSlackHaltReason.NO_VALID_ORGANIZATION
)

@property
def dm_data(self) -> Mapping[str, Any]:
return self.data.get("event", {})
Expand Down
98 changes: 15 additions & 83 deletions src/sentry/integrations/slack/webhooks/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import all_silo_endpoint
from sentry.constants import ObjectStatus
from sentry.integrations.messaging.metrics import (
MessagingInteractionEvent,
MessagingInteractionType,
Expand All @@ -34,11 +33,8 @@
from sentry.integrations.slack.unfurl.types import LinkType, UnfurlableUrl
from sentry.integrations.slack.views.link_identity import build_linking_url
from sentry.integrations.types import IntegrationProviderSlug
from sentry.models.organization import Organization, OrganizationStatus
from sentry.organizations.services.organization import organization_service
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.seer.entrypoints.slack.entrypoint import SlackExplorerEntrypoint
from sentry.seer.entrypoints.slack.messaging import send_identity_link_prompt
from sentry.seer.entrypoints.slack.tasks import process_mention_for_slack

from .base import SlackDMEndpoint
Expand Down Expand Up @@ -368,74 +364,6 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo

return True

def _resolve_seer_organization(self, slack_request: SlackEventRequest) -> SeerResolutionResult:
"""
Resolve and validate an organization/user for a Seer Slack event.

If the initiating user is not linked, we will reply with a prompt to link their identity.

Then we search for an active, organization with Seer Explorer access. If the user does not
belong to any matched organization, their request will be dropped.

Note: There is a limitation here of only grabbing the first organization belonging to the user
with access to Seer. If a Slack installation corresponds to multiple organizations with Seer
access, this will not work as expected. This will be revisited.
"""
result: SeerResolutionResult = {
"organization_id": None,
"installation": None,
"error_reason": None,
}

identity_user = slack_request.get_identity_user()
if not identity_user:
result["error_reason"] = SeerSlackHaltReason.IDENTITY_NOT_LINKED
send_identity_link_prompt(
integration=slack_request.integration,
slack_user_id=slack_request.user_id,
channel_id=slack_request.channel_id,
thread_ts=slack_request.thread_ts or None,
is_welcome_message=slack_request.is_assistant_thread_event,
)
return result

ois = integration_service.get_organization_integrations(
integration_id=slack_request.integration.id,
status=ObjectStatus.ACTIVE,
providers=SLACK_PROVIDERS,
)
if not ois:
result["error_reason"] = SeerSlackHaltReason.NO_VALID_INTEGRATION
return result

for oi in ois:
organization_id = oi.organization_id
try:
organization = Organization.objects.get_from_cache(id=organization_id)
except Organization.DoesNotExist:
continue

if organization.status != OrganizationStatus.ACTIVE:
continue

if not SlackExplorerEntrypoint.has_access(organization):
continue

if not organization.has_access(identity_user):
continue

installation = slack_request.integration.get_installation(
organization_id=organization_id
)
assert isinstance(installation, SlackIntegration)

result["organization_id"] = organization_id
result["installation"] = installation
return result

result["error_reason"] = SeerSlackHaltReason.NO_VALID_ORGANIZATION
return result

def _handle_seer_prompt(
self,
slack_request: SlackEventRequest,
Expand All @@ -461,16 +389,17 @@ def _handle_seer_prompt(
}
)

result = self._resolve_seer_organization(slack_request)
if result["error_reason"]:
lifecycle.record_halt(result["error_reason"])
organization_id, error_reason = slack_request.resolve_seer_organization()
if error_reason:
lifecycle.record_halt(error_reason)
return self.respond()

if not result["organization_id"] or not result["installation"]:
if not organization_id:
return self.respond()

organization_id = result["organization_id"]
installation = result["installation"]
installation: SlackIntegration = slack_request.integration.get_installation(
organization_id=organization_id
)

if not channel_id or not text or not ts or not slack_request.user_id:
lifecycle.record_halt(SeerSlackHaltReason.MISSING_EVENT_DATA)
Expand Down Expand Up @@ -523,15 +452,17 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo
spec=SlackMessagingSpec(),
).capture() as lifecycle:
lifecycle.add_extra("integration_id", slack_request.integration.id)
result = self._resolve_seer_organization(slack_request)
if result["error_reason"]:
lifecycle.record_halt(result["error_reason"])
organization_id, error_reason = slack_request.resolve_seer_organization()
if error_reason:
lifecycle.record_halt(error_reason)
return self.respond()

if not result["installation"]:
if not organization_id:
return self.respond()

installation = result["installation"]
installation: SlackIntegration = slack_request.integration.get_installation(
organization_id=organization_id
)

channel_id = slack_request.channel_id
thread_ts = slack_request.thread_ts
Expand All @@ -542,6 +473,7 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo
"channel_id": channel_id,
"thread_ts": thread_ts,
"context": assistant_thread.get("context"),
"organization_id": organization_id,
}
)

Expand Down
54 changes: 52 additions & 2 deletions src/sentry/middleware/integrations/parsers/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from sentry.integrations.models.integration import Integration
from sentry.integrations.slack.message_builder.routing import SlackRoutingData, decode_action_id
from sentry.integrations.slack.requests.base import SlackRequestError
from sentry.integrations.slack.requests.event import is_event_challenge
from sentry.integrations.slack.requests.event import SlackEventRequest, is_event_challenge
from sentry.integrations.slack.sdk_client import SlackSdkClient
from sentry.integrations.slack.views import SALT
from sentry.integrations.slack.views.link_identity import SlackLinkIdentityView
Expand All @@ -41,7 +41,8 @@
from sentry.integrations.slack.webhooks.options_load import SlackOptionsLoadEndpoint
from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders
from sentry.middleware.integrations.tasks import convert_to_async_slack_response
from sentry.types.cell import Cell
from sentry.models.organizationmapping import OrganizationMapping
from sentry.types.cell import Cell, CellResolutionError, get_cell_by_name
from sentry.utils import json
from sentry.utils.signing import unsign

Expand Down Expand Up @@ -293,6 +294,50 @@
)
return organizations

def _maybe_get_response_from_event_request(self) -> HttpResponseBase | None:
if self.view_class != SlackEventEndpoint:
return None
drf_request: Request = SlackDMEndpoint().initialize_request(self.request)
slack_request: SlackEventRequest = self.view_class.slack_request_class(drf_request)

if not slack_request.is_seer_explorer_request:

Check failure on line 303 in src/sentry/middleware/integrations/parsers/slack.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

[RA8-UWS] RuntimeError when resolve_seer_organization accesses self.integration without validation (additional location)

The new `resolve_seer_organization()` method accesses `self.integration` at lines 98 and 109, but `self.integration` raises `RuntimeError` when `_integration` is None. The `_integration` field is only populated by `_get_context()` which is called from `validate()`. The middleware parser (`_maybe_get_response_from_event_request` in slack.py:297-306) creates a `SlackEventRequest` and calls `resolve_seer_organization()` without first calling `validate()`, causing a RuntimeError to be raised.
return None

organization_id, error_reason = slack_request.resolve_seer_organization()
if not organization_id or error_reason:
logger.info(
"slack.control.seer_event.organization.not_found",
extra={
"organization_id": organization_id,
"error_reason": error_reason,
},
)
return None

try:
mapping = OrganizationMapping.objects.get(organization_id=organization_id)
except OrganizationMapping.DoesNotExist:
logger.info(
"slack.control.seer_event.organization.mapping.not_found",
extra={
"organization_id": organization_id,
},
)
return None

try:
cell = get_cell_by_name(mapping.cell_name)
except CellResolutionError:
logger.info(
"slack.control.seer_event.organization.cell.not_found",
extra={
"organization_id": organization_id,
"cell_name": mapping.cell_name,
},
)
return None
return self.get_response_from_cell_silo(cell=cell)

def get_response(self) -> HttpResponseBase:
"""
Slack Webhook Requests all require synchronous responses.
Expand Down Expand Up @@ -322,6 +367,7 @@
# this request until it succeeds.
return HttpResponse(status=status.HTTP_202_ACCEPTED)

drf_request: Request
if self.view_class == SlackActionEndpoint:
drf_request: Request = SlackDMEndpoint().initialize_request(self.request)
slack_request = self.view_class.slack_request_class(drf_request)
Expand All @@ -337,6 +383,10 @@
else self.get_response_from_all_cells()
)

event_response = self._maybe_get_response_from_event_request()
if event_response:
return event_response

# Slack webhooks can only receive one synchronous call/response, as there are many
# places where we post to slack on their webhook request. This would cause multiple
# calls back to slack for every cell we forward to.
Expand Down
3 changes: 2 additions & 1 deletion src/sentry/seer/entrypoints/slack/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
SeerExplorerResponse,
)
from sentry.notifications.utils.actions import BlockKitMessageAction
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.seer.autofix.utils import AutofixStoppingPoint
from sentry.seer.entrypoints.cache import SeerOperatorAutofixCache
from sentry.seer.entrypoints.registry import (
Expand Down Expand Up @@ -420,7 +421,7 @@ def __init__(
self.slack_user_id = slack_user_id

@staticmethod
def has_access(organization: Organization) -> bool:
def has_access(organization: Organization | RpcOrganization) -> bool:
has_seer_slack_feature_flag = features.has(
"organizations:seer-slack-explorer", organization
)
Expand Down
Loading
Loading