Skip to content

Commit

Permalink
Merge branch 'master' into improve_fix/releases/armenzg
Browse files Browse the repository at this point in the history
  • Loading branch information
armenzg authored Nov 15, 2024
2 parents e771176 + 8dbe565 commit 9d27591
Show file tree
Hide file tree
Showing 105 changed files with 2,184 additions and 1,609 deletions.
6 changes: 3 additions & 3 deletions api-docs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ component-emitter@^1.2.0:
integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==

cookiejar@^2.1.0:
version "2.1.3"
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc"
integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==
version "2.1.4"
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b"
integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==

core-util-is@~1.0.0:
version "1.0.3"
Expand Down
29 changes: 29 additions & 0 deletions fixtures/backup/model_dependencies/detailed.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@
"table_name": "flags_audit_log",
"uniques": []
},
"flags.flagwebhooksigningsecretmodel": {
"dangling": false,
"foreign_keys": {
"created_by": {
"kind": "HybridCloudForeignKey",
"model": "sentry.user",
"nullable": true
},
"organization": {
"kind": "FlexibleForeignKey",
"model": "sentry.organization",
"nullable": false
}
},
"model": "flags.flagwebhooksigningsecretmodel",
"relocation_dependencies": [],
"relocation_scope": "Excluded",
"silos": [
"Region"
],
"table_name": "flags_webhooksigningsecret",
"uniques": [
[
"organization",
"provider",
"secret"
]
]
},
"hybridcloud.apikeyreplica": {
"dangling": false,
"foreign_keys": {
Expand Down
4 changes: 4 additions & 0 deletions fixtures/backup/model_dependencies/flat.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"flags.flagauditlogmodel": [
"sentry.organization"
],
"flags.flagwebhooksigningsecretmodel": [
"sentry.organization",
"sentry.user"
],
"hybridcloud.apikeyreplica": [
"sentry.apikey",
"sentry.organization"
Expand Down
1 change: 1 addition & 0 deletions fixtures/backup/model_dependencies/sorted.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"hybridcloud.organizationslugreservationreplica",
"hybridcloud.externalactorreplica",
"hybridcloud.apikeyreplica",
"flags.flagwebhooksigningsecretmodel",
"flags.flagauditlogmodel",
"feedback.feedback",
"uptime.projectuptimesubscription",
Expand Down
1 change: 1 addition & 0 deletions fixtures/backup/model_dependencies/truncate.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"hybridcloud_organizationslugreservationreplica",
"hybridcloud_externalactorreplica",
"hybridcloud_apikeyreplica",
"flags_webhooksigningsecret",
"flags_audit_log",
"feedback_feedback",
"uptime_projectuptimesubscription",
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,6 @@ module = [
"sentry.tasks.auth",
"sentry.tasks.base",
"sentry.testutils.cases",
"sentry.testutils.fixtures",
"sentry.testutils.helpers.notifications",
"sentry.utils.auth",
"sentry.utils.committers",
Expand Down
7 changes: 7 additions & 0 deletions self-hosted/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ LABEL org.opencontainers.image.authors="[email protected]"
# add our user and group first to make sure their IDs get assigned consistently
RUN groupadd -r sentry && useradd -r -m -g sentry sentry

RUN : \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
libexpat1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

ARG GOSU_VERSION=1.17
ARG GOSU_SHA256=bbc4136d03ab138b1ad66fa4fc051bafc6cc7ffae632b069a53657279a450de3
ARG TINI_VERSION=0.19.0
Expand Down
8 changes: 7 additions & 1 deletion src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
OrganizationFlagLogDetailsEndpoint,
OrganizationFlagLogIndexEndpoint,
)
from sentry.flags.endpoints.secrets import OrganizationFlagsWebHookSigningSecretEndpoint
from sentry.incidents.endpoints.organization_alert_rule_activations import (
OrganizationAlertRuleActivationsEndpoint,
)
Expand Down Expand Up @@ -2064,10 +2065,15 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
name="sentry-api-0-organization-flag-log",
),
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/flags/hooks/provider/(?P<provider>[\w-]+)/token/(?P<token>.+)/$",
r"^(?P<organization_id_or_slug>[^\/]+)/flags/hooks/provider/(?P<provider>[\w-]+)/$",
OrganizationFlagsHooksEndpoint.as_view(),
name="sentry-api-0-organization-flag-hooks",
),
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/flags/hooks/provider/(?P<provider>[\w-]+)/signing-secret/$",
OrganizationFlagsWebHookSigningSecretEndpoint.as_view(),
name="sentry-api-0-organization-flag-hooks-signing-secret",
),
# Replays
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/replays/$",
Expand Down
20 changes: 19 additions & 1 deletion src/sentry/flags/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,30 @@ Retrieve a single flag log instance.
}
```

## Webhooks [/organizations/<organization_id_or_slug>/flags/hooks/provider/<provider>/token/<token>/]
## Signing Secret [/organizations/<organization_id_or_slug>/flags/hooks/provider/<provider>/signing-secret/]

### Create Signing Secret [POST]

Requests from web hook providers can be signed. We use the signing secret to verify the webhook's origin is authentic.

- Request (application/json)

```json
{
"secret": "d41d7d1adced450d9e2eb7f76dde6a04"
}
```

- Response 201

## Webhooks [/organizations/<organization_id_or_slug>/flags/hooks/provider/<provider>/]

### Create Flag Log [POST]

The shape of the request object varies by provider. The `<provider>` URI parameter informs the server of the shape of the request and it is on the server to handle the provider. The following providers are supported: Unleash, Split, Statsig, and LaunchDarkly.

Webhooks are signed by their provider. The provider handler must use the secret stored in Sentry to verify the signature of the payload. Failure to do so could lead to unauthorized access.

**Flag Pole Example:**

Flag pole is Sentry owned. It matches our audit-log resource because it is designed for that purpose.
Expand Down
12 changes: 12 additions & 0 deletions src/sentry/flags/endpoints/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from sentry.api.bases.organization import OrganizationEndpoint
from sentry.api.exceptions import ResourceDoesNotExist

VALID_PROVIDERS = {"launchdarkly"}


class OrganizationFlagsEndpoint(OrganizationEndpoint):

def convert_args(self, *args, **kwargs):
if kwargs.get("provider", "") not in VALID_PROVIDERS:
raise ResourceDoesNotExist
return super().convert_args(*args, **kwargs)
84 changes: 13 additions & 71 deletions src/sentry/flags/endpoints/hooks.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,29 @@
import logging
from urllib.parse import unquote

import sentry_sdk
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, region_silo_endpoint
from sentry.api.base import region_silo_endpoint
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.flags.endpoints import OrganizationFlagsEndpoint
from sentry.flags.providers import (
DeserializationError,
InvalidProvider,
handle_provider_event,
validate_provider_event,
write,
)
from sentry.hybridcloud.models.orgauthtokenreplica import OrgAuthTokenReplica
from sentry.models.organization import Organization
from sentry.models.orgauthtoken import OrgAuthToken
from sentry.silo.base import SiloMode
from sentry.utils.security.orgauthtoken_token import hash_token

"""HTTP endpoint.
This endpoint accepts only organization authorization tokens. I've made the conscious
decision to exclude all other forms of authentication. We don't want users accidentally
writing logs or leaked DSNs generating invalid log entries. An organization token is
secret and reasonably restricted and so makes sense for this use case where we have
inter-provider communication.
"""

logger = logging.getLogger()


@region_silo_endpoint
class OrganizationFlagsHooksEndpoint(Endpoint):
class OrganizationFlagsHooksEndpoint(OrganizationFlagsEndpoint):
authentication_classes = ()
owner = ApiOwner.REPLAY
permission_classes = ()
publish_status = {
"POST": ApiPublishStatus.PRIVATE,
}

def convert_args(
self,
request: Request,
organization_id_or_slug: str,
token: str,
*args,
**kwargs,
):
try:
if str(organization_id_or_slug).isdigit():
organization = Organization.objects.get_from_cache(id=organization_id_or_slug)
else:
organization = Organization.objects.get_from_cache(slug=organization_id_or_slug)
except Organization.DoesNotExist:
raise ResourceDoesNotExist

if not is_valid_token(organization.id, token):
raise AuthenticationFailed("Invalid token specified.")

kwargs["organization"] = organization
return args, kwargs
publish_status = {"POST": ApiPublishStatus.PRIVATE}

def post(self, request: Request, organization: Organization, provider: str) -> Response:
if not features.has(
Expand All @@ -73,35 +32,18 @@ def post(self, request: Request, organization: Organization, provider: str) -> R
return Response("Not enabled.", status=404)

try:
if not validate_provider_event(
provider,
request.body,
request.headers,
organization.id,
):
return Response("Not authorized.", status=401)

write(handle_provider_event(provider, request.data, organization.id))
return Response(status=200)
except InvalidProvider:
raise ResourceDoesNotExist
except DeserializationError as exc:
sentry_sdk.capture_exception()
return Response(exc.errors, status=200)


def is_valid_token(organization_id: int, token: str) -> bool:
token_hashed = hash_token(unquote(token))

if SiloMode.get_current_mode() == SiloMode.REGION:
try:
OrgAuthTokenReplica.objects.get(
token_hashed=token_hashed,
date_deactivated__isnull=True,
organization_id=organization_id,
)
return True
except OrgAuthTokenReplica.DoesNotExist:
return False
else:
try:
OrgAuthToken.objects.get(
token_hashed=token_hashed,
date_deactivated__isnull=True,
organization_id=organization_id,
)
return True
except OrgAuthToken.DoesNotExist:
return False
47 changes: 47 additions & 0 deletions src/sentry/flags/endpoints/secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from datetime import datetime, timezone

from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.flags.endpoints import OrganizationFlagsEndpoint
from sentry.flags.models import FlagWebHookSigningSecretModel
from sentry.models.organization import Organization


class FlagWebhookSigningSecretValidator(serializers.Serializer):
secret = serializers.CharField(required=True)


@region_silo_endpoint
class OrganizationFlagsWebHookSigningSecretEndpoint(OrganizationFlagsEndpoint):
authentication_classes = ()
owner = ApiOwner.REPLAY
permission_classes = ()
publish_status = {"POST": ApiPublishStatus.PRIVATE}

def post(self, request: Request, organization: Organization, provider: str) -> Response:
if not features.has(
"organizations:feature-flag-audit-log", organization, actor=request.user
):
return Response("Not enabled.", status=404)

validator = FlagWebhookSigningSecretValidator(data=request.data)
if not validator.is_valid():
return self.respond(validator.errors, status=400)

FlagWebHookSigningSecretModel.objects.create_or_update(
organization=organization,
provider=provider,
values={
"created_by": request.user.id,
"date_added": datetime.now(tz=timezone.utc),
"secret": validator.validated_data["secret"],
},
)

return Response(status=201)
Loading

0 comments on commit 9d27591

Please sign in to comment.