Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- (snapshots) Compress preprod snapshot manifest with zstd ([#3336](https://github.com/getsentry/sentry-cli/pull/3336))

### Fixes

- (auth) Route org token requests to region_url ([#3342](https://github.com/getsentry/sentry-cli/pull/3342))

## 3.5.1

### Internal Changes 🔧
Expand Down
6 changes: 3 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ impl Config {
let manually_configured_url = configured_url(&ini);
let token_url = token_embedded_data
.as_ref()
.map(|td| td.url.as_str())
.map(|td| td.base_url())
.unwrap_or_default();

let url = if token_url.is_empty() {
Expand Down Expand Up @@ -201,7 +201,7 @@ impl Config {
Some(Auth::Token(ref val)) => {
self.cached_token_data = val.payload().cloned();

if let Some(token_url) = self.cached_token_data.as_ref().map(|td| td.url.as_str()) {
if let Some(token_url) = self.cached_token_data.as_ref().map(|td| td.base_url()) {
self.cached_base_url = token_url.to_owned();
}

Expand Down Expand Up @@ -232,7 +232,7 @@ impl Config {
let token_url = self
.cached_token_data
.as_ref()
.map(|td| td.url.as_str())
.map(|td| td.base_url())
.unwrap_or_default();

if !token_url.is_empty() && url != token_url {
Expand Down
17 changes: 17 additions & 0 deletions src/utils/auth_token/org_auth_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ pub struct AuthTokenPayload {
// URL may be missing from some old auth tokens, see getsentry/sentry#57123
#[serde(deserialize_with = "url_deserializer")]
pub url: String,

// Region URL is the host that actually serves this org's API requests. It is
// absent from older tokens, in which case requests fall back to `url`.
#[serde(default, deserialize_with = "url_deserializer")]
pub region_url: String,

@lcian lcian Jun 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub region_url: String,
pub region_url: Option<String>,

This would be the proper type, instead of using an empty string as a sentinel value

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And I guess the reason the clanker used String is that we're gonna need a new opt_url_deserializer to do it that way

}

impl AuthTokenPayload {
/// Returns the base URL that API requests for this org should target,
/// preferring the region URL and falling back to `url` when it is absent.
pub fn base_url(&self) -> &str {
if self.region_url.is_empty() {
&self.url
} else {
&self.region_url
}
Comment on lines +33 to +37

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part makes sense to me as most CLI operations (and org auth tokens in general) work on regional resources. This would make it so that org-auth-tokens can't be used on anything that involves control though.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on this: Broadly switching all operations to prefer the region URL could be risky; we should validate that all endpoints supported by org auth tokens also are available on the region.

Less risky could be to expose a way to get the region URL while still maintaining a way to get the base URL, although that is likely a bit complex. That would allow us to use the region URL where needed and for commands where we have validated that it works, while continuing to use the base URL elsewhere.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that an old version of CLI had this utility

pub fn region_specific(&'a self, org: &'a str) -> RegionSpecificApi<'a> {

Maybe relevant for the discussion here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we had used this for the pre-chunked-uploading uploads that were removed in Sentry CLI 3.x. We no longer needed it for chunked uploads, as the chunked upload endpoint already returns the region-specific URL as the upload URL, which is why this was removed.

If we go with a fix in the CLI, though, we might want something like this utility so that we only migrate the API calls that need to go to the region-specific endpoints over to the region specific URLs; that lowers the risk of this change by keeping most things on control silo.

}
}

/// Deserializes a URL from a string, returning an empty string if the URL is missing or null.
Expand Down
45 changes: 45 additions & 0 deletions src/utils/auth_token/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,54 @@ fn test_valid_org_auth_token() {
let payload = token.payload().unwrap();
assert_eq!(payload.org, "sentry");
assert_eq!(payload.url, "http://localhost:8000");
assert_eq!(payload.region_url, "http://localhost:8000");
assert_eq!(payload.base_url(), "http://localhost:8000");

assert_eq!(good_token, token.raw().expose_secret().clone());

assert!(token.format_recognized());
}

#[test]
fn test_valid_org_auth_token_region_url_differs() {
// Payload: {"url":"http://control.example","region_url":"http://region.example","org":"sentry"}
let good_token = String::from(
"sntrys_\
eyJpYXQiOiAxNzA0MjA1ODAyLjE5OTc0MywgInVybCI6ICJodHRwOi8vY29udHJvbC5leGFtcGxlIiw\
gInJlZ2lvbl91cmwiOiAiaHR0cDovL3JlZ2lvbi5leGFtcGxlIiwgIm9yZyI6ICJzZW50cnkifQ==_\
lQ5ETt61cHhvJa35fxvxARsDXeVrd0pu4/smF4sRieA",
);

let token = AuthToken::from(good_token);
let payload = token.payload().unwrap();

assert_eq!(payload.org, "sentry");
assert_eq!(payload.url, "http://control.example");
assert_eq!(payload.region_url, "http://region.example");
// base_url prefers region_url when present.
assert_eq!(payload.base_url(), "http://region.example");
}

#[test]
fn test_valid_org_auth_token_missing_region_url() {
// Legacy token with no region_url field at all; base_url falls back to url.
// Payload: {"url":"http://legacy.example","org":"sentry"}
let good_token = String::from(
"sntrys_\
eyJpYXQiOiAxNzA0MjA1ODAyLjE5OTc0MywgInVybCI6ICJodHRwOi8vbGVnYWN5LmV4YW1wbGUiL\
CAib3JnIjogInNlbnRyeSJ9_\
lQ5ETt61cHhvJa35fxvxARsDXeVrd0pu4/smF4sRieA",
);

let token = AuthToken::from(good_token);
let payload = token.payload().unwrap();

assert_eq!(payload.org, "sentry");
assert_eq!(payload.url, "http://legacy.example");
assert!(payload.region_url.is_empty());
assert_eq!(payload.base_url(), "http://legacy.example");
}

#[test]
fn test_valid_org_auth_token_missing_url() {
let good_token = String::from(
Expand All @@ -43,6 +85,9 @@ fn test_valid_org_auth_token_missing_url() {
let payload = token.payload().unwrap();
assert_eq!(payload.org, "sentry");
assert!(payload.url.is_empty());
// url is null but region_url is present, so base_url falls back to region_url.
assert_eq!(payload.region_url, "http://localhost:8000");
assert_eq!(payload.base_url(), "http://localhost:8000");

assert_eq!(good_token, token.raw().expose_secret().clone());

Expand Down
10 changes: 10 additions & 0 deletions tests/integration/_cases/org_tokens/region-url-routing.trycmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
A region-scoped org token where url (control silo) differs from region_url (the org's
region host). The region_url governs routing: it is preferred over the token's url and
overrides --url. Payload: {"url":"http://control.example","region_url":"http://region.example","org":"sentry"}
```
$ sentry-cli --auth-token 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 --url http://example.com info
? failed
[..]WARN[..] Using http://region.example (embedded in token) rather than manually-configured URL http://example.com. To use http://example.com, please provide an auth token for this URL.
...

```
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
This auth token has an empty URL. We expect the --url argument to take precedence here
This token has an empty url but a non-empty region_url. The region_url now governs
routing, so it overrides the --url argument (the warning references the region_url).
```
$ sentry-cli --auth-token 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 --url https://sentry.example.com info
? failed
Sentry Server: https://sentry.example.com
[..]WARN[..] Using http://localhost:8000 (embedded in token) rather than manually-configured URL https://sentry.example.com. To use https://sentry.example.com, please provide an auth token for this URL.
...

```
Loading