relay_server/endpoints/
project_configs.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use axum::extract::{Query, Request};
5use axum::handler::Handler;
6use axum::http::StatusCode;
7use axum::response::{IntoResponse, Result};
8use axum::{Json, RequestExt};
9use relay_base_schema::project::ProjectKey;
10use relay_dynamic_config::{ErrorBoundary, GlobalConfig};
11use serde::{Deserialize, Serialize};
12
13use crate::endpoints::common::ServiceUnavailable;
14use crate::endpoints::forward;
15use crate::extractors::SignedJson;
16use crate::service::ServiceState;
17use crate::services::global_config::{self, StatusResponse};
18use crate::services::projects::project::{
19    LimitedParsedProjectState, ParsedProjectState, ProjectState, Revision,
20};
21use crate::utils::ApiErrorResponse;
22
23/// V3 version of this endpoint.
24///
25/// This version allows returning `pending` project configs, so anything in the cache can be
26/// returned directly.  The pending projectconfigs will also be fetched from upstream, so
27/// next time a downstream relay polls for this it is hopefully in our cache and will be
28/// returned, or a further poll ensues.
29const ENDPOINT_V3: u16 = 3;
30
31#[derive(Debug, Clone, Copy, thiserror::Error)]
32#[error("This API version is no longer supported, upgrade your Relay or Client")]
33struct VersionOutdatedError;
34
35impl IntoResponse for VersionOutdatedError {
36    fn into_response(self) -> axum::response::Response {
37        (StatusCode::BAD_REQUEST, ApiErrorResponse::from_error(&self)).into_response()
38    }
39}
40
41/// Helper to deserialize the `version` query parameter.
42#[derive(Clone, Copy, Debug, Deserialize)]
43struct VersionQuery {
44    #[serde(default)]
45    version: u16,
46}
47
48/// The type returned for each requested project config.
49///
50/// Wrapper on top the project state which encapsulates information about how ProjectState
51/// should be deserialized. The `Limited` deserializes using a class with a subset of the
52/// original fields.
53///
54/// Full configs are only returned to internal relays which also requested the full config.
55#[derive(Debug, Clone, Serialize)]
56#[serde(untagged)]
57enum ProjectStateWrapper {
58    Full(ParsedProjectState),
59    Limited(#[serde(with = "LimitedParsedProjectState")] ParsedProjectState),
60}
61
62impl ProjectStateWrapper {
63    /// Create a wrapper which forces serialization into external or internal format
64    pub fn new(state: ParsedProjectState, full: bool) -> Self {
65        if full {
66            Self::Full(state)
67        } else {
68            Self::Limited(state)
69        }
70    }
71}
72
73/// The response type to the V2 request.
74///
75/// Either the project config is returned or `None` in case the requesting Relay did not have
76/// permission for the config, or the config does not exist.  The latter happens if the request was
77/// made by an external relay who's public key is not configured as authorised on the project.
78///
79/// Version 3 also adds a list of projects whose response is pending.  A [`ProjectKey`] should never
80/// be in both collections. This list is always empty before V3. If `global` is
81/// enabled, version 3 also responds with [`GlobalConfig`].
82#[derive(Debug, Serialize)]
83#[serde(rename_all = "camelCase")]
84struct GetProjectStatesResponseWrapper {
85    configs: HashMap<ProjectKey, ProjectStateWrapper>,
86    #[serde(skip_serializing_if = "Vec::is_empty")]
87    pending: Vec<ProjectKey>,
88    #[serde(skip_serializing_if = "Vec::is_empty")]
89    unchanged: Vec<ProjectKey>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    global: Option<Arc<GlobalConfig>>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    global_status: Option<StatusResponse>,
94}
95
96/// Request payload of the project config endpoint.
97///
98/// This is a replica of [`GetProjectStates`](crate::services::projects::source::upstream::GetProjectStates)
99/// which allows skipping invalid project keys.
100#[derive(Debug, Deserialize)]
101#[serde(rename_all = "camelCase")]
102struct GetProjectStatesRequest {
103    /// The list of all requested project configs.
104    public_keys: Vec<ErrorBoundary<ProjectKey>>,
105    /// List of revisions for all project configs.
106    ///
107    /// This length of this list if specified must be the same length
108    /// as [`Self::public_keys`], the items are asssociated by index.
109    revisions: Option<ErrorBoundary<Vec<Revision>>>,
110    #[serde(default)]
111    full_config: bool,
112    #[serde(default)]
113    global: bool,
114}
115
116fn into_valid_keys(
117    public_keys: Vec<ErrorBoundary<ProjectKey>>,
118    revisions: Option<ErrorBoundary<Vec<Revision>>>,
119) -> impl Iterator<Item = (ProjectKey, Revision)> {
120    let mut revisions = revisions.and_then(|e| e.ok()).unwrap_or_default();
121    if !revisions.is_empty() && revisions.len() != public_keys.len() {
122        // The downstream sent us a different amount of revisions than project keys,
123        // this indicates an error in the downstream code. Just to be safe, discard
124        // all revisions and carry on as if the downstream never sent any revisions.
125        relay_log::warn!(
126            "downstream sent {} project keys but {} revisions, discarding all revisions",
127            public_keys.len(),
128            revisions.len()
129        );
130        revisions.clear();
131    }
132    let revisions = revisions
133        .into_iter()
134        .chain(std::iter::repeat_with(Revision::default));
135
136    std::iter::zip(public_keys, revisions).filter_map(|(public_key, revision)| {
137        // Skip unparsable public keys.
138        // The downstream Relay will consider them `ProjectState::missing`.
139        let public_key = public_key.ok()?;
140        Some((public_key, revision))
141    })
142}
143
144async fn inner(
145    state: ServiceState,
146    body: SignedJson<GetProjectStatesRequest>,
147) -> Result<impl IntoResponse, ServiceUnavailable> {
148    let SignedJson { inner, relay } = body;
149
150    let (global, global_status) = if inner.global {
151        match state.global_config().send(global_config::Get).await? {
152            global_config::Status::Ready(config) => (Some(config), Some(StatusResponse::Ready)),
153            // Old relays expect to get a global config no matter what, even if it's not ready
154            // yet. We therefore give them a default global config.
155            global_config::Status::Pending => (
156                Some(GlobalConfig::default().into()),
157                Some(StatusResponse::Pending),
158            ),
159        }
160    } else {
161        (None, None)
162    };
163
164    let keys_len = inner.public_keys.len();
165    let mut pending = Vec::with_capacity(keys_len);
166    let mut unchanged = Vec::with_capacity(keys_len);
167    let mut configs = HashMap::with_capacity(keys_len);
168
169    for (project_key, revision) in into_valid_keys(inner.public_keys, inner.revisions) {
170        let project = state.project_cache_handle().get(project_key);
171
172        let project_info = match project.state() {
173            ProjectState::Enabled(info) => info,
174            ProjectState::Disabled => {
175                // Don't insert project config. Downstream Relay will consider it disabled.
176                continue;
177            }
178            ProjectState::Pending => {
179                pending.push(project_key);
180                continue;
181            }
182        };
183
184        // Only ever omit responses when there was a valid revision in the first place.
185        if project_info.rev == revision {
186            unchanged.push(project_key);
187            continue;
188        }
189
190        // If public key is known (even if rate-limited, which is Some(false)), it has
191        // access to the project config
192        let has_access = relay.internal
193            || project_info
194                .config
195                .trusted_relays
196                .contains(&relay.public_key);
197
198        if has_access {
199            let full = relay.internal && inner.full_config;
200            let wrapper = ProjectStateWrapper::new(
201                ParsedProjectState {
202                    disabled: false,
203                    info: project_info.as_ref().clone(),
204                },
205                full,
206            );
207            configs.insert(project_key, wrapper);
208        } else {
209            relay_log::debug!(
210                relay = %relay.public_key,
211                project_key = %project_key,
212                "relay does not have access to project key",
213            );
214        };
215    }
216
217    Ok(Json(GetProjectStatesResponseWrapper {
218        configs,
219        pending,
220        unchanged,
221        global,
222        global_status,
223    }))
224}
225
226/// Returns `true` for all `?version` query parameters that are no longer supported by Relay and Sentry.
227fn is_outdated(Query(query): Query<VersionQuery>) -> bool {
228    query.version < ENDPOINT_V3
229}
230
231/// Returns `true` if the `?version` query parameter is compatible with this implementation.
232fn is_compatible(Query(query): Query<VersionQuery>) -> bool {
233    query.version == ENDPOINT_V3
234}
235
236/// Endpoint handler for the project configs endpoint.
237///
238/// # Version Compatibility
239///
240/// This endpoint checks a `?version` query parameter for compatibility. If this implementation is
241/// compatible with the version requested by the client (downstream Relay), it runs the project
242/// config endpoint implementation. Otherwise, the request is forwarded to the upstream.
243///
244/// Relays can drop compatibility with old versions of the project config endpoint, for instance the
245/// initial version 1. However, Sentry's HTTP endpoint will retain compatibility for much longer to
246/// support old Relay versions.
247pub async fn handle(state: ServiceState, mut req: Request) -> Result<impl IntoResponse> {
248    let data = req.extract_parts().await?;
249    if is_outdated(data) {
250        Err(VersionOutdatedError.into())
251    } else if is_compatible(data) {
252        Ok(inner.call(req, state).await)
253    } else {
254        Ok(forward::forward(state, req).await)
255    }
256}