Skip to content
Merged
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
8 changes: 8 additions & 0 deletions src/sentry/auth/services/auth/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from django.contrib.auth.models import AnonymousUser

from sentry.auth.provider import Provider
from sentry.models.authprovider import ScimTokenDisplay


class RpcApiKey(RpcModel):
Expand Down Expand Up @@ -204,6 +205,13 @@ def get_scim_token(self) -> str | None:

return get_scim_token(self.flags.scim_enabled, self.organization_id, self.provider)

def get_scim_token_for_display(self) -> Optional["ScimTokenDisplay"]:
from sentry.models.authprovider import get_scim_token_for_display

return get_scim_token_for_display(
self.flags.scim_enabled, self.organization_id, self.provider
)


class RpcAuthIdentity(RpcModel):
id: int = -1
Expand Down
66 changes: 66 additions & 0 deletions src/sentry/models/authprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,72 @@ def sanitize_relocation_json(
sanitizer.set_string(json, SanitizableField(model_name, "provider"))


class ScimTokenDisplay:
"""
Represents a SCIM token for display purposes.

If the token was created more than TOKEN_VISIBILITY_WINDOW_SECONDS ago,
is_visible will be False and only the last 4 characters should be shown.
"""

TOKEN_VISIBILITY_WINDOW_SECONDS = 300 # 5 minutes

def __init__(
self,
token: str | None,
token_last_characters: str | None,
is_visible: bool,
):
self.token = token
self.token_last_characters = token_last_characters
self.is_visible = is_visible


def get_scim_token_for_display(
scim_enabled: bool, organization_id: int, provider: str
) -> ScimTokenDisplay | None:
"""
Get SCIM token info for display in the UI with proper masking.

Tokens are only fully visible for 5 minutes after creation.
After that, only the last 4 characters are shown.

All models involved (SentryAppInstallationToken, ApiToken,
SentryAppInstallationForProvider) are control silo models,
so we can query directly without RPC.
"""
from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken

if not scim_enabled:
return None

tokens = list(
SentryAppInstallationToken.objects.select_related("api_token").filter(
sentry_app_installation__sentryappinstallationforprovider__organization_id=organization_id,
sentry_app_installation__sentryappinstallationforprovider__provider=f"{provider}_scim",
)
)
if not tokens:
return None

if len(tokens) > 1:
logger.warning(
"Multiple SCIM tokens found for organization",
extra={"organization_id": organization_id, "token_count": len(tokens)},
)

api_token = tokens[0].api_token
is_visible = (
timezone.now() - api_token.date_added
).total_seconds() < ScimTokenDisplay.TOKEN_VISIBILITY_WINDOW_SECONDS

return ScimTokenDisplay(
token=api_token.token if is_visible else None,
token_last_characters=api_token.token[-4:],
is_visible=is_visible,
)


def get_scim_token(scim_enabled: bool, organization_id: int, provider: str) -> str | None:
from sentry.sentry_apps.services.app import app_service

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,20 @@ <h3>{{ provider_name }} Authentication</h3>
</div>
</div>

{% if scim_api_token %}
{% if scim_token_display %}
<div class="box">
<div class="box-header">
<h3>SCIM Information</h3>
</div>
<div class="box-content with-padding">
<b>Auth Token:</b>
<pre>{{ scim_api_token }}</pre>
{% if scim_token_display.is_visible %}
<pre>{{ scim_token_display.token }}</pre>
<p class="help-block">Your token is only available briefly after creation. Make sure to save this value!</p>
{% else %}
<pre>************{{ scim_token_display.token_last_characters }}</pre>
<p class="help-block">To generate a new token, disable and re-enable SCIM.</p>
{% endif %}
<b>SCIM URL:</b>
<pre>{{ scim_url }}</pre>
<p>See provider specific SCIM documentation <a href="https://docs.sentry.io/product/accounts/sso/#scim-provisioning">here</a>.</p>
Expand Down
4 changes: 3 additions & 1 deletion src/sentry/web/frontend/organization_auth_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ def handle_existing_provider(
pending_links_count = organization_service.count_members_without_sso(
organization_id=organization.id
)
scim_token_display = auth_provider.get_scim_token_for_display()

context = {
"form": form,
"pending_links_count": pending_links_count,
Expand All @@ -234,7 +236,7 @@ def handle_existing_provider(
),
"auth_provider": auth_provider,
"provider_name": provider.name,
"scim_api_token": auth_provider.get_scim_token(),
"scim_token_display": scim_token_display,
"scim_url": get_scim_url(auth_provider, organization),
"content": response,
"disabled": provider.is_partner,
Expand Down
106 changes: 106 additions & 0 deletions tests/sentry/web/frontend/test_organization_auth_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -811,3 +811,109 @@ def test_update_generic_saml2_config(self) -> None:

assert actual.provider == self.auth_provider_inst.provider
assert actual.flags == self.auth_provider_inst.flags


@control_silo_test
class OrganizationAuthSettingsScimTokenMaskingTest(AuthProviderTestCase):
"""Tests for SCIM token masking security feature (VULN-789)."""

def create_org_and_auth_provider(self, provider_name="dummy"):
self.user.update(is_managed=True)
with assume_test_silo_mode(SiloMode.REGION):
organization = self.create_organization(name="foo", owner=self.user)

auth_provider = AuthProvider.objects.create(
organization_id=organization.id, provider=provider_name
)
AuthIdentity.objects.create(user=self.user, ident="foo", auth_provider=auth_provider)
return organization, auth_provider

def create_om_and_link_sso(self, organization):
with assume_test_silo_mode(SiloMode.REGION):
om = OrganizationMember.objects.get(user_id=self.user.id, organization=organization)
setattr(om.flags, "sso:linked", True)
om.save()
return om

def test_scim_token_visible_immediately_after_creation(self):
"""SCIM token is fully visible within 5 minutes of creation."""
organization, auth_provider = self.create_org_and_auth_provider()
self.create_om_and_link_sso(organization)
path = reverse("sentry-organization-auth-provider-settings", args=[organization.slug])

self.login_as(self.user, organization_id=organization.id)

with self.feature({"organizations:sso-basic": True}):
resp = self.client.post(
path,
{
"op": "settings",
"require_link": True,
"enable_scim": True,
"default_role": "member",
},
)
assert resp.status_code == 200

auth_provider = AuthProvider.objects.get(organization_id=organization.id)
assert auth_provider.flags.scim_enabled

resp = self.client.get(path)
assert resp.status_code == 200

assert "scim_token_display" in resp.context
scim_token_display = resp.context["scim_token_display"]
assert scim_token_display is not None
assert scim_token_display.is_visible is True
assert scim_token_display.token is not None

def test_scim_token_masked_after_visibility_window(self):
"""SCIM token is masked after 5 minutes."""
from datetime import timedelta

from django.utils import timezone

from sentry.models.apitoken import ApiToken
from sentry.sentry_apps.models.sentry_app_installation_token import (
SentryAppInstallationToken,
)

organization, auth_provider = self.create_org_and_auth_provider()
self.create_om_and_link_sso(organization)
path = reverse("sentry-organization-auth-provider-settings", args=[organization.slug])

self.login_as(self.user, organization_id=organization.id)

with self.feature({"organizations:sso-basic": True}):
resp = self.client.post(
path,
{
"op": "settings",
"require_link": True,
"enable_scim": True,
"default_role": "member",
},
)
assert resp.status_code == 200

auth_provider = AuthProvider.objects.get(organization_id=organization.id)
assert auth_provider.flags.scim_enabled

install_for_provider = SentryAppInstallationForProvider.objects.get(
organization_id=organization.id, provider="dummy_scim"
)
install_token = SentryAppInstallationToken.objects.get(
sentry_app_installation=install_for_provider.sentry_app_installation
)
old_date = timezone.now() - timedelta(minutes=10)
ApiToken.objects.filter(id=install_token.api_token_id).update(date_added=old_date)

resp = self.client.get(path)
assert resp.status_code == 200

scim_token_display = resp.context["scim_token_display"]
assert scim_token_display is not None
assert scim_token_display.is_visible is False
assert scim_token_display.token is None
assert scim_token_display.token_last_characters is not None
assert len(scim_token_display.token_last_characters) == 4
Loading