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(dev-toolbar): Add organization derived API applications #74598

Closed
wants to merge 34 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c6e106c
Add organization scoped applications
cmanallen Jul 19, 2024
9e5aeb5
Add scoping_organization_id to ApiToken and replica models and valida…
cmanallen Jul 19, 2024
e956fb1
Add docs
cmanallen Jul 22, 2024
b687686
Update docs
cmanallen Jul 22, 2024
3cf7b8c
Remove docs
cmanallen Jul 22, 2024
a15a78c
Set scoping_organization_id on the generated api token
cmanallen Jul 22, 2024
84ecca0
Cascade foreign-key deletes
cmanallen Jul 22, 2024
f21c3cc
Add migrations
cmanallen Jul 22, 2024
a9e3c68
Merge branch 'master' into cmanallen/dev-toolbar-add-public-applications
cmanallen Jul 22, 2024
5ac2d41
Fix migration order
cmanallen Jul 22, 2024
44d8358
Add stubs for test coverage
cmanallen Jul 22, 2024
b009633
Check for auth property before accessing
cmanallen Jul 22, 2024
fd10f0e
Handling organization scoping through the property
cmanallen Jul 23, 2024
002b1bc
Remove middleware
cmanallen Jul 23, 2024
29fd3c5
Update authorization logic to check for organization scoping for non-…
cmanallen Jul 23, 2024
3eed17b
Make comment more clear
cmanallen Jul 23, 2024
6ab55ba
Remove useless coverage
cmanallen Jul 23, 2024
afb7c4a
Merge branch 'master' into cmanallen/dev-toolbar-add-public-applications
cmanallen Jul 23, 2024
9088c64
Fix coverage
cmanallen Jul 23, 2024
301f8ea
Check for auth
cmanallen Jul 23, 2024
e7e398c
Remove old migration coverage
cmanallen Jul 23, 2024
4aadb7f
Add organization api applications endpoint
cmanallen Jul 25, 2024
35b2d99
Update scoping rules
cmanallen Jul 25, 2024
6f7b5a5
Add feature flag
cmanallen Jul 29, 2024
f9ee8b1
Add coverage
cmanallen Jul 29, 2024
8999298
Expire token in 12 hours
cmanallen Jul 29, 2024
8da4c9b
Add coverage for token expiration
cmanallen Jul 29, 2024
0f18cf7
Merge branch 'master' into cmanallen/dev-toolbar-add-public-applications
cmanallen Jul 29, 2024
f4067d0
Update dependency fixtures
cmanallen Jul 29, 2024
53a9347
Add audit-log entries
cmanallen Jul 29, 2024
b92ae83
Fix typing
cmanallen Jul 29, 2024
732b949
Update routes
cmanallen Jul 29, 2024
0d360d2
Update snapshots
cmanallen Jul 29, 2024
4a6577e
Merge branch 'master' into cmanallen/dev-toolbar-add-public-applications
cmanallen Aug 9, 2024
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
13 changes: 13 additions & 0 deletions fixtures/backup/model_dependencies/detailed.json
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,11 @@
"sentry.apiapplication": {
"dangling": false,
"foreign_keys": {
"organization_id": {
"kind": "HybridCloudForeignKey",
"model": "sentry.organization",
"nullable": true
},
"owner": {
"kind": "FlexibleForeignKey",
"model": "sentry.user",
Expand All @@ -588,6 +593,9 @@
"uniques": [
[
"client_id"
],
[
"organization_id"
]
]
},
Expand Down Expand Up @@ -675,6 +683,11 @@
"model": "sentry.apiapplication",
"nullable": true
},
"scoping_organization_id": {
"kind": "HybridCloudForeignKey",
"model": "sentry.organization",
"nullable": true
},
"user": {
"kind": "FlexibleForeignKey",
"model": "sentry.user",
Expand Down
2 changes: 2 additions & 0 deletions fixtures/backup/model_dependencies/flat.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"sentry.querysubscription"
],
"sentry.apiapplication": [
"sentry.organization",
"sentry.user"
],
"sentry.apiauthorization": [
Expand All @@ -97,6 +98,7 @@
],
"sentry.apitoken": [
"sentry.apiapplication",
"sentry.organization",
"sentry.user"
],
"sentry.artifactbundle": [
Expand Down
2 changes: 1 addition & 1 deletion migrations_lockfile.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ hybridcloud: 0016_add_control_cacheversion
nodestore: 0002_nodestore_no_dictfield
remote_subscriptions: 0003_drop_remote_subscription
replays: 0004_index_together
sentry: 0747_create_datasecrecywaiver_table
sentry: 0748_add_public_api_applications
social_auth: 0002_default_auto_field
uptime: 0006_projectuptimesubscription_name_owner
164 changes: 164 additions & 0 deletions src/sentry/api/endpoints/organization_api_application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Management interface for organization defined and scoped API applications.

As of time of writing this resource is used exclusively for provisioning API applications for the
Sentry dev-toolbar. A unique constraint is placed on the API application's organization foreign-key
limiting the number of organiation-tied API applications to one.

The purpose of this resource is to allow users to authenticate with Sentry from a remote origin. In
other words, a Sentry user is authorizing a piece of code not under the control of Sentry to make
requests on their behalf to Sentry servers.
"""

from rest_framework import serializers, status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListField

from sentry import audit_log, features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import control_silo_endpoint
from sentry.api.bases.organization import (
ControlSiloOrganizationEndpoint,
OrganizationAdminPermission,
)
from sentry.api.serializers import Serializer, serialize
from sentry.models.apiapplication import ApiApplication


class ApiApplicationValidator(serializers.Serializer):
allowedOrigins = ListField(
child=serializers.URLField(max_length=255),
required=True,
allow_null=False,
)
redirectUris = ListField(
child=serializers.URLField(max_length=255),
required=True,
allow_null=False,
)


class ApiApplicationSerializer(Serializer):
def serialize(self, obj: ApiApplication, attrs, user, **kwargs):
return {
"id": obj.client_id,
"allowedOrigins": obj.get_allowed_origins(),
"redirectUris": obj.get_redirect_uris(),
}


@control_silo_endpoint
class OrganizationApiApplicationIndexEndpoint(ControlSiloOrganizationEndpoint):
owner = ApiOwner.ECOSYSTEM
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
"POST": ApiPublishStatus.EXPERIMENTAL,
"PUT": ApiPublishStatus.EXPERIMENTAL,
"DELETE": ApiPublishStatus.EXPERIMENTAL,
}
permission_classes = (OrganizationAdminPermission,)

def get(self, request: Request, organization_context, organization) -> Response:
"""Fetch organization API Application."""
if not features.has(
"organizations:oauth2-public-api-applications", organization, actor=request.user
):
return Response(status=404)

try:
application = ApiApplication.objects.filter(organization_id=organization.id).get()
return Response(
serialize(application, request.user, ApiApplicationSerializer()),
status=status.HTTP_200_OK,
)
except ApiApplication.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)

def post(self, request: Request, organization_context, organization) -> Response:
"""Create organization API Application."""
if not features.has(
"organizations:oauth2-public-api-applications", organization, actor=request.user
):
return Response(status=404)

serializer = ApiApplicationValidator(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

result = serializer.validated_data

try:
ApiApplication.objects.filter(organization_id=organization.id).get()
return Response(status=status.HTTP_200_OK)
except ApiApplication.DoesNotExist:
pass

application = ApiApplication.objects.create(
owner_id=request.user.id,
allowed_origins="\n".join(result["allowedOrigins"]),
redirect_uris="\n".join(result["redirectUris"]),
organization_id=organization.id,
)

self.create_audit_entry(
request,
organization=organization,
event=audit_log.get_event_id("ORGANIZATION_APIAPPLICATION_ADD"),
)

return Response(
serialize(application, request.user, ApiApplicationSerializer()),
status=status.HTTP_201_CREATED,
)

def put(self, request: Request, organization_context, organization) -> Response:
if not features.has(
"organizations:oauth2-public-api-applications", organization, actor=request.user
):
return Response(status=404)

serializer = ApiApplicationValidator(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

result = serializer.validated_data

try:
application = ApiApplication.objects.filter(organization_id=organization.id).get()
application.allowed_origins = "\n".join(result["allowedOrigins"])
application.redirect_uris = "\n".join(result["redirectUris"])
application.save()
except ApiApplication.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)

self.create_audit_entry(
request,
organization=organization,
event=audit_log.get_event_id("ORGANIZATION_APIAPPLICATION_UPDATE"),
)

return Response(
serialize(application, request.user, ApiApplicationSerializer()),
status=status.HTTP_202_ACCEPTED,
)

def delete(self, request: Request, organization_context, organization) -> Response:
if not features.has(
"organizations:oauth2-public-api-applications", organization, actor=request.user
):
return Response(status=404)

try:
application = ApiApplication.objects.filter(organization_id=organization.id).get()
application.delete()
except ApiApplication.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)

self.create_audit_entry(
request,
organization=organization,
event=audit_log.get_event_id("ORGANIZATION_APIAPPLICATION_DELETE"),
)

return Response(status=status.HTTP_204_NO_CONTENT)
25 changes: 15 additions & 10 deletions src/sentry/api/serializers/models/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,16 +707,21 @@ def _is_authorized(user, organization_id: int):
if request and is_active_superuser(request) and request.user.id == user.id:
return True

# If user is a sentry_app then it's a proxy user meaning we can't do a org lookup via `get_orgs()`
# because the user isn't an org member. Instead we can use the auth token and the installation
# it's associated with to find out what organization the token has access to.
if (
request
and getattr(request.user, "is_sentry_app", False)
and is_api_token_auth(request.auth)
):
if AuthenticatedToken.from_token(request.auth).token_has_org_access(organization_id):
return True
if request and hasattr(request, "auth") and is_api_token_auth(request.auth):
token = AuthenticatedToken.from_token(request.auth)

if getattr(request.user, "is_sentry_app", False):
# This code implies that if the token does not have org access we should
# continue execution. This is how it was when I found it. Is this correct?
# Should Sentry Apps always be organization scoped?
if token.token_has_org_access(organization_id):
return True
else:
# We only evaluate organization access if an organization_id is present
# on the token. Otherwise we fall-through to the default authorization
# flow.
if token.organization_id:
return token.token_has_org_access(organization_id)

if (
request
Expand Down
8 changes: 8 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
from sentry.api.endpoints.issues.related_issues import RelatedIssuesEndpoint
from sentry.api.endpoints.org_auth_token_details import OrgAuthTokenDetailsEndpoint
from sentry.api.endpoints.org_auth_tokens import OrgAuthTokensEndpoint
from sentry.api.endpoints.organization_api_application import (
OrganizationApiApplicationIndexEndpoint,
)
from sentry.api.endpoints.organization_events_root_cause_analysis import (
OrganizationEventsRootCauseAnalysisEndpoint,
)
Expand Down Expand Up @@ -1353,6 +1356,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationAccessRequestDetailsEndpoint.as_view(),
name="sentry-api-0-organization-access-request-details",
),
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/api-application/$",
OrganizationApiApplicationIndexEndpoint.as_view(),
name="sentry-api-0-organization-api-application",
),
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/api-keys/$",
OrganizationApiKeyIndexEndpoint.as_view(),
Expand Down
26 changes: 26 additions & 0 deletions src/sentry/audit_log/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,32 @@
)
)

default_manager.add(
AuditLogEvent(
event_id=191,
name="ORGANIZATION_APIAPPLICATION_ADD",
api_name="organization-api-application.create",
template="added api application",
)
)

default_manager.add(
AuditLogEvent(
event_id=192,
name="ORGANIZATION_APIAPPLICATION_UPDATE",
api_name="organization-api-application.update",
template="updated api application",
)
)

default_manager.add(
AuditLogEvent(
event_id=193,
name="ORGANIZATION_APIAPPLICATION_DELETE",
api_name="organization-api-application.delete",
template="deleted api application",
)
)
default_manager.add(
AuditLogEvent(
event_id=200,
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:session-replay-ui", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True)
# Enable replay web vital breadcrumbs
manager.add("organizations:session-replay-web-vitals", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, default=False, api_expose=True)
# Enable OAuth2 public Api Applications
manager.add("organizations:oauth2-public-api-applications", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, default=False, api_expose=True)
# Lets organizations manage grouping configs
manager.add("organizations:set-grouping-config", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=True)
# Enable description field in Slack metric alerts
Expand Down
43 changes: 43 additions & 0 deletions src/sentry/migrations/0748_add_public_api_applications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 5.0.6 on 2024-07-22 19:55

from django.db import migrations

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 = [
("sentry", "0747_create_datasecrecywaiver_table"),
]

operations = [
migrations.AddField(
model_name="apiapplication",
name="organization_id",
field=sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey(
"sentry.Organization", db_index=True, null=True, on_delete="CASCADE", unique=True
),
),
migrations.AddField(
model_name="apitoken",
name="scoping_organization_id",
field=sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey(
"sentry.Organization", db_index=True, null=True, on_delete="CASCADE"
),
),
]
16 changes: 16 additions & 0 deletions src/sentry/models/apiapplication.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
control_silo_model,
sane_repr,
)
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
from sentry.db.models.manager.base import BaseManager
from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope
from sentry.models.outbox import ControlOutbox, outbox_context
Expand Down Expand Up @@ -65,6 +66,21 @@ class ApiApplication(Model):
privacy_url = models.URLField(null=True)
terms_url = models.URLField(null=True)

# API applications can be tied to an untrusted organization.
#
# This represents several security risks. A phishing risk and cross organization access for
# the integrator. Before creating an access_token for a user, verify the user has access to
# the organization defined on the application. Restrict the access_token to only access the
# organization defined on the application. Cross-organization access is not tolerable
# regardless of the token's scope.
organization_id = HybridCloudForeignKey(
"sentry.Organization",
db_index=True,
null=True,
unique=True,
on_delete="CASCADE",
)

date_added = models.DateTimeField(default=timezone.now)

objects: ClassVar[BaseManager[Self]] = BaseManager(cache_fields=("client_id",))
Expand Down
Loading
Loading