Skip to content

Commit 211b9b3

Browse files
IanWoodardshashjar
authored andcommitted
feat(conduit): Add conduit auth functions (#101729)
Adds backend helpers for generating Conduit authentication credentials. [Conduit](https://github.com/getsentry/conduit) is Sentry's real-time delivery platform that enables scalable streaming from backend services to frontend clients. This PR provides the foundational utilities needed for API endpoints to generate Conduit credentials (JWT tokens and channel IDs) that the [conduit-client](https://github.com/getsentry/conduit-client) will use to establish streaming connections. **Changes** - New utilities in `src/sentry/conduit/auth.py` - `generate_channel_id()`: UUID4 generation for stream identifiers - `generate_conduit_token()`: RS256 signed JWT with org_id, channel_id, and 10 minute expiration - `get_conduit_credentials()`: Returns `{url, token, channel_id}` dict for the client - Configuration `src/sentry/conf/server.py` - `CONDUIT_PRIVATE_KEY` (required): RSA private key for JWT signing - `CONDUIT_GATEWAY_URL`: Gateway base URL - `CONDUIT_JWT_ISSUER` / `CONDUIT_JWT_AUDIENCE`: JWT claims Note: Requires `CONDUIT_PRIVATE_KEY` environment variable to be set before use.
1 parent d8f31a9 commit 211b9b3

File tree

5 files changed

+274
-0
lines changed

5 files changed

+274
-0
lines changed

src/sentry/conduit/__init__.py

Whitespace-only changes.

src/sentry/conduit/auth.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import time
2+
import uuid
3+
from typing import NamedTuple
4+
5+
from django.conf import settings
6+
7+
from sentry.utils import jwt, metrics
8+
9+
TOKEN_TTL_SEC = 600 # 10 minutes
10+
11+
12+
class ConduitCredentials(NamedTuple):
13+
token: str
14+
channel_id: str
15+
url: str
16+
17+
18+
def generate_channel_id() -> str:
19+
"""Generate a unique channel ID for a Conduit stream."""
20+
return str(uuid.uuid4())
21+
22+
23+
def generate_conduit_token(
24+
org_id: int,
25+
channel_id: str,
26+
issuer: str | None = None,
27+
audience: str | None = None,
28+
conduit_private_key: str | None = None,
29+
) -> str:
30+
"""
31+
Generate a JWT token for Conduit authentication.
32+
33+
Optional parameters default to settings values if not provided.
34+
35+
Returns:
36+
JWT token string
37+
"""
38+
if issuer is None:
39+
issuer = settings.CONDUIT_JWT_ISSUER
40+
if audience is None:
41+
audience = settings.CONDUIT_JWT_AUDIENCE
42+
if conduit_private_key is None:
43+
conduit_private_key = settings.CONDUIT_PRIVATE_KEY
44+
if conduit_private_key is None:
45+
raise ValueError("CONDUIT_PRIVATE_KEY not configured")
46+
47+
now = int(time.time())
48+
exp = now + TOKEN_TTL_SEC
49+
payload = {
50+
"org_id": org_id,
51+
"channel_id": channel_id,
52+
"iat": now,
53+
# Conduit only validates tokens on initial connection, not for stream lifetime
54+
"exp": exp,
55+
"iss": issuer,
56+
"aud": audience,
57+
}
58+
return jwt.encode(payload, conduit_private_key, algorithm="RS256")
59+
60+
61+
def get_conduit_credentials(
62+
org_id: int,
63+
gateway_url: str | None = None,
64+
) -> ConduitCredentials:
65+
"""
66+
Generate all credentials needed to connect to Conduit.
67+
68+
Returns:
69+
ConduitCredentials containing authentication details
70+
"""
71+
if gateway_url is None:
72+
gateway_url = settings.CONDUIT_GATEWAY_URL
73+
channel_id = generate_channel_id()
74+
token = generate_conduit_token(org_id, channel_id)
75+
76+
metrics.incr(
77+
"conduit.credentials.generated",
78+
sample_rate=1.0,
79+
)
80+
81+
return ConduitCredentials(
82+
token=token,
83+
channel_id=channel_id,
84+
url=f"{gateway_url}/events/{org_id}",
85+
)

src/sentry/conf/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3177,3 +3177,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
31773177
# the region API URL template is set to the ngrok host.
31783178
SENTRY_OPTIONS["system.region-api-url-template"] = f"https://{{region}}.{ngrok_host}"
31793179
SENTRY_FEATURES["system:multi-region"] = True
3180+
3181+
CONDUIT_PRIVATE_KEY: str | None = os.getenv("CONDUIT_PRIVATE_KEY")
3182+
CONDUIT_GATEWAY_URL: str = os.getenv("CONDUIT_GATEWAY_URL", "https://conduit.sentry.io")
3183+
CONDUIT_JWT_ISSUER: str = os.getenv("CONDUIT_JWT_ISSUER", "sentry")
3184+
CONDUIT_JWT_AUDIENCE: str = os.getenv("CONDUIT_JWT_AUDIENCE", "conduit")

tests/sentry/conduit/__init__.py

Whitespace-only changes.

tests/sentry/conduit/test_auth.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import time
2+
from unittest.mock import patch
3+
4+
import jwt as pyjwt
5+
import pytest
6+
7+
from sentry.conduit.auth import generate_channel_id, generate_conduit_token, get_conduit_credentials
8+
from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY
9+
10+
11+
def test_generate_channel_id_is_valid_uuid():
12+
"""Should generate a valid uuid."""
13+
channel_id = generate_channel_id()
14+
15+
# UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
16+
assert isinstance(channel_id, str)
17+
assert len(channel_id) == 36 # Length of UUID
18+
assert channel_id.count("-") == 4
19+
20+
21+
def test_generate_channel_id_is_unique():
22+
"""Should generate unique channel_ids."""
23+
assert generate_channel_id() != generate_channel_id()
24+
25+
26+
def test_generate_conduit_token_is_valid_jwt():
27+
"""Should generate a valid JWT token with RS256."""
28+
org_id = 123
29+
channel_id = "ad342057-d66b-4ed4-ab01-3415dd2cb1ce"
30+
31+
token = generate_conduit_token(
32+
org_id,
33+
channel_id,
34+
issuer="sentry",
35+
audience="conduit",
36+
conduit_private_key=RS256_KEY,
37+
)
38+
39+
assert isinstance(token, str)
40+
assert token.count(".") == 2
41+
42+
claims = pyjwt.decode(token, RS256_PUB_KEY, algorithms=["RS256"], audience="conduit")
43+
44+
assert claims["channel_id"] == channel_id
45+
assert claims["org_id"] == org_id
46+
assert claims["iss"] == "sentry"
47+
assert claims["aud"] == "conduit"
48+
assert "iat" in claims
49+
assert "exp" in claims
50+
51+
52+
def test_generate_conduit_token_has_expiration():
53+
"""Token should expire in 10 minutes."""
54+
org_id = 123
55+
channel_id = "ad342057-d66b-4ed4-ab01-3415dd2cb1ce"
56+
57+
before_time = int(time.time())
58+
token = generate_conduit_token(
59+
org_id,
60+
channel_id,
61+
issuer="sentry",
62+
audience="conduit",
63+
conduit_private_key=RS256_KEY,
64+
)
65+
after_time = int(time.time())
66+
67+
claims = pyjwt.decode(
68+
token,
69+
RS256_PUB_KEY,
70+
algorithms=["RS256"],
71+
audience="conduit",
72+
options={"verify_exp": False},
73+
)
74+
75+
exp_time = claims["exp"]
76+
iat_time = claims["iat"]
77+
78+
assert iat_time >= before_time
79+
assert iat_time <= after_time
80+
assert exp_time == iat_time + 600
81+
82+
83+
def test_generate_conduit_token_uses_settings():
84+
"""Should use settings when parameters are not provided."""
85+
org_id = 123
86+
channel_id = "ad342057-d66b-4ed4-ab01-3415dd2cb1ce"
87+
88+
with patch("sentry.conduit.auth.settings") as mock_settings:
89+
mock_settings.CONDUIT_PRIVATE_KEY = RS256_KEY
90+
mock_settings.CONDUIT_JWT_ISSUER = "test-issuer"
91+
mock_settings.CONDUIT_JWT_AUDIENCE = "test-audience"
92+
93+
token = generate_conduit_token(
94+
org_id,
95+
channel_id,
96+
)
97+
98+
claims = pyjwt.decode(
99+
token,
100+
RS256_PUB_KEY,
101+
algorithms=["RS256"],
102+
audience="test-audience",
103+
options={"verify_exp": False},
104+
)
105+
106+
assert claims["iss"] == "test-issuer"
107+
assert claims["aud"] == "test-audience"
108+
109+
110+
def test_generate_conduit_token_raises_when_missing():
111+
"""Should raise an error if the private key is not configured."""
112+
org_id = 123
113+
channel_id = "ad342057-d66b-4ed4-ab01-3415dd2cb1ce"
114+
with pytest.raises(ValueError, match="CONDUIT_PRIVATE_KEY not configured"):
115+
generate_conduit_token(
116+
org_id,
117+
channel_id,
118+
)
119+
120+
121+
def test_get_conduit_credentials_returns_all_credentials():
122+
"""Should return a url, token, and channel_id."""
123+
gateway_url = "https://conduit.example.com"
124+
with patch("sentry.conduit.auth.settings") as mock_settings:
125+
mock_settings.CONDUIT_PRIVATE_KEY = RS256_KEY
126+
mock_settings.CONDUIT_JWT_ISSUER = "sentry"
127+
mock_settings.CONDUIT_JWT_AUDIENCE = "conduit"
128+
mock_settings.CONDUIT_GATEWAY_URL = gateway_url
129+
130+
org_id = 123
131+
result = get_conduit_credentials(org_id)
132+
133+
assert isinstance(result.token, str)
134+
assert isinstance(result.channel_id, str)
135+
assert isinstance(result.url, str)
136+
137+
assert str(org_id) in result.url
138+
assert result.url == f"{gateway_url}/events/{org_id}"
139+
140+
141+
def test_get_conduit_credentials_uses_custom_url():
142+
"""Should use provided gateway_url instead of settings."""
143+
gateway_url = "https://custom.conduit.io"
144+
with patch("sentry.conduit.auth.settings") as mock_settings:
145+
mock_settings.CONDUIT_PRIVATE_KEY = RS256_KEY
146+
mock_settings.CONDUIT_JWT_ISSUER = "sentry"
147+
mock_settings.CONDUIT_JWT_AUDIENCE = "conduit"
148+
149+
org_id = 123
150+
result = get_conduit_credentials(org_id, gateway_url)
151+
152+
assert isinstance(result.token, str)
153+
assert isinstance(result.channel_id, str)
154+
assert isinstance(result.url, str)
155+
156+
assert str(org_id) in result.url
157+
assert result.url == f"{gateway_url}/events/{org_id}"
158+
159+
160+
def test_get_conduit_credentials_token_is_valid():
161+
"""Generated token should be decodable with correct claims."""
162+
gateway_url = "https://conduit.example.com"
163+
with patch("sentry.conduit.auth.settings") as mock_settings:
164+
mock_settings.CONDUIT_PRIVATE_KEY = RS256_KEY
165+
mock_settings.CONDUIT_JWT_ISSUER = "sentry"
166+
mock_settings.CONDUIT_JWT_AUDIENCE = "conduit"
167+
mock_settings.CONDUIT_GATEWAY_URL = gateway_url
168+
169+
org_id = 123
170+
result = get_conduit_credentials(org_id)
171+
172+
claims = pyjwt.decode(
173+
result.token,
174+
RS256_PUB_KEY,
175+
algorithms=["RS256"],
176+
audience="conduit",
177+
options={"verify_exp": False},
178+
)
179+
180+
assert claims["org_id"] == org_id
181+
assert claims["channel_id"] == result.channel_id
182+
183+
assert str(org_id) in result.url
184+
assert result.url == f"{gateway_url}/events/{org_id}"

0 commit comments

Comments
 (0)