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
2 changes: 1 addition & 1 deletion src/sentry/analytics/events/sentry_app_deleted.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

@analytics.eventclass("sentry_app.deleted")
class SentryAppDeletedEvent(analytics.Event):
user_id: int
user_id: int | None
organization_id: int
sentry_app: str

Expand Down
2 changes: 1 addition & 1 deletion src/sentry/analytics/events/sentry_app_updated.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

@analytics.eventclass("sentry_app.updated")
class SentryAppUpdatedEvent(analytics.Event):
user_id: int
user_id: int | None
organization_id: int
sentry_app: str
created_alert_rule_ui_component: bool | None = None
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/auth_organization_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
)
from sentry.auth import access
from sentry.auth.exceptions import ProviderNotRegistered
from sentry.auth.principal import acting_user_id, require_user
from sentry.auth.principal import acting_user_id, has_user, require_user
from sentry.constants import WARN_SESSION_EXPIRED
from sentry.demo_mode.utils import is_demo_mode_enabled, is_demo_org
from sentry.models.authprovider import AuthProvider
Expand Down Expand Up @@ -98,7 +98,7 @@ def get(self, request: Request, organization_id_or_slug: str) -> Response:

warnings = []
if (
request.user.is_authenticated
has_user(request.user)
and organization_context.member is None
and not organization_access.has_global_access
and not is_demo_organization
Expand Down
6 changes: 3 additions & 3 deletions src/sentry/api/endpoints/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.serializers import serialize
from sentry.auth.principal import require_user
from sentry.auth.principal import has_user
from sentry.users.models.user import User


Expand All @@ -16,8 +16,8 @@ class IndexEndpoint(Endpoint):
permission_classes = ()

def get(self, request: Request) -> Response:
if request.user.is_authenticated:
user = User.objects.get(id=require_user(request).id)
if has_user(request.user):
user = User.objects.get(id=request.user.id)
user = serialize(user, user)
else:
user = None
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/api/endpoints/organization_api_key_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class OrganizationApiKeyDetailsEndpoint(ControlSiloOrganizationEndpoint):
"PUT": ApiPublishStatus.PRIVATE,
}
permission_classes = (OrganizationAdminPermission,)
# Keys show their secret and take any scope, so a service account could exceed its own.
requires_user = True

def convert_args(
self, request: Request, api_key_id: str, *args: Any, **kwargs: Any
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/api/endpoints/organization_api_key_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class OrganizationApiKeyIndexEndpoint(ControlSiloOrganizationEndpoint):
"POST": ApiPublishStatus.PRIVATE,
}
permission_classes = (OrganizationAdminPermission,)
# Keys show their secret and take any scope, so a service account could exceed its own.
requires_user = True

def get(self, request: Request, organization_context, organization) -> Response:
"""
Expand Down
32 changes: 23 additions & 9 deletions src/sentry/auth/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@
from sentry.seer.agent_token import is_agent_auth
from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.service_accounts.auth import is_service_account_auth
from sentry.service_accounts.models import ServiceAccountProjectReplica, ServiceAccountReplica
from sentry.service_accounts.models import (
ServiceAccount,
ServiceAccountProjectReplica,
ServiceAccountReplica,
)
from sentry.silo.base import SiloMode
from sentry.types.request import _HttpRequestWithUser
from sentry.users.models.user import User
Expand Down Expand Up @@ -1528,15 +1532,25 @@ def from_service_account_auth(auth: AuthenticatedToken, organization_id: int) ->
# Bound to the account's own org, like agent tokens.
if auth.entity_id is None or auth.organization_id != organization_id:
return DEFAULT
# The token doesn't carry `all_projects`, so read it from the replica, like the
# project links. An account deactivated since authentication gets no access.
all_projects = (
ServiceAccountReplica.objects.filter(
service_account_id=auth.entity_id, organization_id=organization_id, is_active=True
# The token doesn't carry `all_projects`, so read it from the account: the source
# on control, the replica elsewhere. An account deactivated since authentication
# gets no access.
if SiloMode.get_current_mode() == SiloMode.CONTROL:
all_projects = (
ServiceAccount.objects.filter(
id=auth.entity_id, organization_id=organization_id, is_active=True
)
.values_list("all_projects", flat=True)
.first()
)
else:
all_projects = (
ServiceAccountReplica.objects.filter(
service_account_id=auth.entity_id, organization_id=organization_id, is_active=True
)
.values_list("all_projects", flat=True)
.first()
)
.values_list("all_projects", flat=True)
.first()
)
if all_projects is None:
return DEFAULT
return ServiceAccountAccess(
Expand Down
22 changes: 17 additions & 5 deletions src/sentry/sentry_apps/api/bases/sentryapps.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
SentryAppPublicErrorBody,
SentryAppSentryError,
)
from sentry.service_accounts.auth import is_service_account_auth
from sentry.users.services.user.service import user_service
from sentry.utils.strings import to_single_line_str

Expand Down Expand Up @@ -263,13 +264,16 @@ def has_object_permission(self, request: Request, view, sentry_app: RpcSentryApp
if superuser_has_permission(request):
return True

user_id = acting_user_id(request.user)
organizations = (
user_service.get_organizations(user_id=request.user.id)
if request.user.id is not None
else ()
user_service.get_organizations(user_id=user_id) if user_id is not None else ()
)
is_owner_service_account = (
is_service_account_auth(request.auth)
and request.auth.organization_id == sentry_app.owner_id
)
# if app is unpublished, user must be in the Org who owns the app.
if not sentry_app.is_published:
if not sentry_app.is_published and not is_owner_service_account:
if not any(sentry_app.owner_id == org.id for org in organizations):
raise SentryAppError(
message="User must be in the app owner's organization for unpublished apps",
Expand Down Expand Up @@ -424,7 +428,15 @@ def has_object_permission(self, request: Request, view, installation):
if request.user.is_sentry_app:
return request.user.id == installation.sentry_app.proxy_user_id

if not org_context.member or org_context.organization.status != OrganizationStatus.ACTIVE:
# A service account has no membership; it belongs to its own organization.
is_org_service_account = (
is_service_account_auth(request.auth)
and request.auth.organization_id == installation.organization_id
)
if (
not (org_context.member or is_org_service_account)
or org_context.organization.status != OrganizationStatus.ACTIVE
):
raise SentryAppError(message="Given organization is not valid", status_code=404)

assert request.method, "method must be present in request to get permissions"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ class SentryAppInstallationExternalIssueActionsEndpoint(
SentryAppInstallationExternalIssueBaseEndpoint
):
owner = ApiOwner.INTEGRATION_PLATFORM
# The app's request names the acting user.
requires_user = True
publish_status = {
"POST": ApiPublishStatus.PUBLIC_EXPERIMENTAL,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class SentryAppExternalRequestOptionsResponse(TypedDict):
@control_silo_endpoint
class SentryAppInstallationExternalRequestsEndpoint(SentryAppInstallationBaseEndpoint):
owner = ApiOwner.INTEGRATION_PLATFORM
# The app's request names the acting user.
requires_user = True
publish_status = {
"GET": ApiPublishStatus.PUBLIC_EXPERIMENTAL,
}
Expand Down
16 changes: 7 additions & 9 deletions src/sentry/sentry_apps/api/endpoints/sentry_app_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
as_validation_errors,
)
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.auth.principal import acting_user_id
from sentry.auth.principal import acting_user_id, require_user
from sentry.auth.staff import is_active_staff
from sentry.constants import SentryAppStatus
from sentry.deletions.models.scheduleddeletion import ScheduledDeletion
Expand All @@ -46,8 +46,6 @@
from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.utils.webhooks import has_error_events
from sentry.users.models.user import User
from sentry.users.services.user.model import RpcUser
from sentry.utils.audit import create_audit_entry

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -173,7 +171,7 @@ def put(
if serializer.is_valid():
result = serializer.validated_data

assert isinstance(request.user, (User, RpcUser)), (
assert request.user.is_authenticated, (
"User must be authenticated to update a Sentry App"
)
updated_app = SentryAppUpdater(
Expand Down Expand Up @@ -255,11 +253,11 @@ def delete(
for install in sentry_app.installations.all():
try:
with transaction.atomic(using=router.db_for_write(SentryAppInstallation)):
assert request.user.is_authenticated, (
"User must be authenticated to delete installation"
)
# The uninstall webhook names the acting user.
SentryAppInstallationNotifier(
sentry_app_installation=install, user=request.user, action="deleted"
sentry_app_installation=install,
user=require_user(request),
action="deleted",
).run()
except RequestException as exc:
sentry_sdk.capture_exception(exc)
Expand All @@ -278,7 +276,7 @@ def delete(
if request.user.is_authenticated:
analytics.record(
SentryAppDeletedEvent(
user_id=request.user.id,
user_id=acting_user_id(request.user),
organization_id=sentry_app.owner_id,
sentry_app=sentry_app.slug,
)
Expand Down
6 changes: 4 additions & 2 deletions src/sentry/sentry_apps/api/serializers/sentry_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from sentry.api.serializers import Serializer, register, serialize
from sentry.app import env
from sentry.auth.principal import acting_user_id
from sentry.auth.staff import is_active_staff
from sentry.auth.superuser import is_active_superuser
from sentry.constants import SentryAppStatus
Expand Down Expand Up @@ -103,7 +104,8 @@ def get_attrs(
for app in ApiApplication.objects.filter(id__in=[i.application_id for i in item_list])
}

user_orgs = user_service.get_organizations(user_id=user.id)
user_id = acting_user_id(user)
user_orgs = user_service.get_organizations(user_id=user_id) if user_id is not None else []
user_org_ids = {uo.id for uo in user_orgs}

# Fetch organization contexts for unique owner_ids where user is a member.
Expand All @@ -114,7 +116,7 @@ def get_attrs(
unique_owner_ids = {i.owner_id for i in item_list if i.owner_id in user_org_ids}
for owner_id in unique_owner_ids:
owner_context = organization_service.get_organization_by_id(
id=owner_id, user_id=user.id
id=owner_id, user_id=user_id
)
if owner_context:
owner_contexts[owner_id] = owner_context
Expand Down
3 changes: 2 additions & 1 deletion src/sentry/sentry_apps/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from sentry.analytics.events.sentry_app_created import SentryAppCreatedEvent
from sentry.analytics.events.sentry_app_updated import SentryAppUpdatedEvent
from sentry.api.helpers.slugs import sentry_slugify
from sentry.auth.principal import acting_user_id
from sentry.auth.staff import has_staff_option
from sentry.constants import SentryAppStatus
from sentry.db.postgres.transactions import in_test_hide_transaction_boundary
Expand Down Expand Up @@ -357,7 +358,7 @@ def record_analytics(self, user: User | RpcUser, new_schema_elements: set[str] |
created_alert_rule_ui_component = "alert-rule-action" in (new_schema_elements or set())
analytics.record(
SentryAppUpdatedEvent(
user_id=user.id,
user_id=acting_user_id(user),
organization_id=self.sentry_app.owner_id,
sentry_app=self.sentry_app.slug,
created_alert_rule_ui_component=created_alert_rule_ui_component,
Expand Down
49 changes: 30 additions & 19 deletions src/sentry/service_accounts/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from sentry import features
from sentry.auth.services.auth import AuthenticatedToken
from sentry.models.organization import Organization
from sentry.service_accounts.models import ServiceAccountReplica
from sentry.organizations.services.organization import RpcOrganization, organization_service
from sentry.service_accounts.models import ServiceAccount, ServiceAccountReplica
from sentry.service_accounts.principal import ServiceAccountPrincipal
from sentry.silo.base import SiloMode
from sentry.viewer_context import ActorType, ViewerContext
Expand All @@ -25,8 +26,9 @@ def authenticate_service_account(
organization.
Only for organizations with ``organizations:service-accounts``. The token carries no
scopes, so they're read from the cell replica on every request. Narrowing, disabling or
deleting the account applies once its outbox replicates, not when the token expires.
scopes, so they're read on every request: from the account itself on control, and from
the cell replica elsewhere. On a cell, narrowing, disabling or deleting the account
applies once its outbox replicates, not when the token expires.
"""
actor = vc.actor
if (
Expand All @@ -35,31 +37,40 @@ def authenticate_service_account(
or vc.organization_id is None
):
return None
# Only cell endpoints accept these contexts, since the replica is a cell table.
# Control endpoints would need to read the control-side ServiceAccount instead.
if SiloMode.get_current_mode() == SiloMode.CONTROL:
return None

service_account = ServiceAccountReplica.objects.filter(
service_account_id=actor.actor_id, organization_id=vc.organization_id, is_active=True
).first()
if service_account is None:
return None
try:
organization = Organization.objects.get_from_cache(id=service_account.organization_id)
except Organization.DoesNotExist:
return None
if not features.has("organizations:service-accounts", organization):
service_account: ServiceAccount | ServiceAccountReplica | None
organization: Organization | RpcOrganization | None
if SiloMode.get_current_mode() == SiloMode.CONTROL:
service_account = ServiceAccount.objects.filter(
id=actor.actor_id, organization_id=vc.organization_id, is_active=True
).first()
if service_account is None:
return None
org_context = organization_service.get_organization_by_id(
id=service_account.organization_id, include_projects=False, include_teams=False
)
organization = org_context.organization if org_context is not None else None
else:
service_account = ServiceAccountReplica.objects.filter(
service_account_id=actor.actor_id, organization_id=vc.organization_id, is_active=True
).first()
if service_account is None:
return None
try:
organization = Organization.objects.get_from_cache(id=service_account.organization_id)
except Organization.DoesNotExist:
organization = None
if organization is None or not features.has("organizations:service-accounts", organization):
return None

principal = ServiceAccountPrincipal(
service_account_id=service_account.service_account_id,
service_account_id=actor.actor_id,
organization_id=service_account.organization_id,
name=service_account.name,
)
token = AuthenticatedToken(
kind=SERVICE_ACCOUNT_TOKEN_KIND,
entity_id=service_account.service_account_id,
entity_id=actor.actor_id,
organization_id=service_account.organization_id,
scopes=sorted(service_account.scope_list),
)
Expand Down
Loading
Loading