Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(flags): Add /secrets resource #80641

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
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
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
8 changes: 7 additions & 1 deletion src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
OrganizationFlagLogDetailsEndpoint,
OrganizationFlagLogIndexEndpoint,
)
from sentry.flags.endpoints.secrets import OrganizationFlagsWebHookSigningSecretEndpoint
from sentry.incidents.endpoints.organization_alert_rule_activations import (
OrganizationAlertRuleActivationsEndpoint,
)
Expand Down Expand Up @@ -2056,10 +2057,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)
cmanallen marked this conversation as resolved.
Show resolved Hide resolved


@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)
aliu39 marked this conversation as resolved.
Show resolved Hide resolved

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={
cmanallen marked this conversation as resolved.
Show resolved Hide resolved
"created_by": request.user.id,
"date_added": datetime.now(tz=timezone.utc),
"secret": validator.validated_data["secret"],
},
)

return Response(status=201)
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Generated by Django 5.1.1 on 2024-11-13 15:32

import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models

import sentry.db.models.fields.bounded
import sentry.db.models.fields.foreignkey
import sentry.db.models.fields.hybrid_cloud_foreign_key
from sentry.new_migrations.migrations import CheckedMigration


class Migration(CheckedMigration):
# This flag is used to mark that a migration shouldn't be automatically run in production.
# This should only be used for operations where it's safe to run the migration after your
# code has deployed. So this should not be used for most operations that alter the schema
# of a table.
# Here are some things that make sense to mark as post deployment:
# - Large data migrations. Typically we want these to be run manually so that they can be
# monitored and not block the deploy for a long period of time while they run.
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
# run this outside deployments so that we don't block them. Note that while adding an index
# is a schema change, it's completely safe to run the operation after the code has deployed.
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment

is_post_deployment = False

dependencies = [
("flags", "0001_add_flag_audit_log"),
("sentry", "0787_make_dashboard_perms_col_nullable"),
]

operations = [
migrations.CreateModel(
name="FlagWebHookSigningSecretModel",
fields=[
(
"id",
sentry.db.models.fields.bounded.BoundedBigAutoField(
primary_key=True, serialize=False
),
),
(
"created_by",
sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey(
"sentry.User", db_index=True, null=True, on_delete="SET_NULL"
),
),
("date_added", models.DateTimeField(default=django.utils.timezone.now)),
("provider", models.CharField(db_index=True)),
("secret", models.CharField()),
(
"organization",
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="sentry.organization"
),
),
],
options={
"db_table": "flags_webhooksigningsecret",
"unique_together": {("organization", "provider", "secret")},
},
),
]
19 changes: 18 additions & 1 deletion src/sentry/flags/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from enum import Enum

from django.conf import settings
from django.db import models
from django.utils import timezone

from sentry.backup.scopes import RelocationScope
from sentry.db.models import Model, region_silo_model, sane_repr
from sentry.db.models import FlexibleForeignKey, Model, region_silo_model, sane_repr
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey


Expand Down Expand Up @@ -83,3 +84,19 @@ class Meta:
indexes = (models.Index(fields=("flag",)),)

__repr__ = sane_repr("organization_id", "flag")


@region_silo_model
class FlagWebHookSigningSecretModel(Model):
__relocation_scope__ = RelocationScope.Excluded

created_by = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL")
date_added = models.DateTimeField(default=timezone.now)
organization = FlexibleForeignKey("sentry.Organization")
provider = models.CharField(db_index=True)
secret = models.CharField()

class Meta:
app_label = "flags"
db_table = "flags_webhooksigningsecret"
unique_together = (("organization", "provider", "secret"),)
Loading
Loading