Skip to main content

devboy_confluence/
liveness.rs

1//! Confluence `LivenessProbe` impl per [ADR-021] §6.
2//!
3//! Self-hosted / Server / DC probes `GET /rest/api/user/current`.
4//! Confluence Cloud uses two paths depending on auth:
5//!
6//! - Basic auth (`email + API token`): probe
7//!   `GET <site>/wiki/rest/api/user/current`
8//! - Bearer auth (Atlassian OAuth): discover the matching
9//!   `cloud_id` via Atlassian `accessible-resources`, then probe
10//!   `GET https://api.atlassian.com/ex/confluence/{cloud_id}/wiki/api/v2/space?limit=1`
11//!
12//! The Cloud bearer path does not return a user profile directly, so
13//! the liveness detail reports the matched site origin / cloud id
14//! rather than a username.
15
16use async_trait::async_trait;
17use devboy_core::{Error, LivenessProbe, LivenessResult, Result};
18use secrecy::{ExposeSecret, SecretString};
19use serde::Deserialize;
20
21use crate::client::{ConfluenceAuth, ConfluenceClient, ConfluenceFlavor};
22
23#[derive(Debug, Deserialize)]
24struct ConfluenceCurrentUser {
25    #[serde(default, rename = "username")]
26    username: Option<String>,
27    #[serde(default, rename = "displayName")]
28    display_name: Option<String>,
29    #[serde(default, rename = "accountId")]
30    account_id: Option<String>,
31    #[serde(default, rename = "email")]
32    email: Option<String>,
33}
34
35#[derive(Debug, Deserialize)]
36struct AtlassianAccessibleResource {
37    id: String,
38    #[serde(default)]
39    url: Option<String>,
40}
41
42#[async_trait]
43impl LivenessProbe for ConfluenceClient {
44    fn provider_name(&self) -> &str {
45        "confluence"
46    }
47
48    async fn test(&self, token: &SecretString) -> Result<LivenessResult> {
49        let http = reqwest::Client::new();
50        match self.flavor() {
51            ConfluenceFlavor::SelfHosted => {
52                probe_current_user(
53                    &http,
54                    &format!("{}/rest/api/user/current", self.base_url()),
55                    self.auth(),
56                    token,
57                    "confluence",
58                )
59                .await
60            }
61            ConfluenceFlavor::Cloud => match self.auth() {
62                ConfluenceAuth::Basic { .. } => {
63                    probe_current_user(
64                        &http,
65                        &format!("{}/wiki/rest/api/user/current", self.base_url()),
66                        self.auth(),
67                        token,
68                        "confluence cloud",
69                    )
70                    .await
71                }
72                ConfluenceAuth::BearerToken(_) | ConfluenceAuth::None => {
73                    probe_cloud_bearer(&http, self, token).await
74                }
75            },
76        }
77    }
78}
79
80async fn probe_current_user(
81    http: &reqwest::Client,
82    url: &str,
83    auth: &ConfluenceAuth,
84    token: &SecretString,
85    provider_label: &str,
86) -> Result<LivenessResult> {
87    let mut request = http
88        .get(url)
89        .header(reqwest::header::ACCEPT, "application/json");
90    request = apply_auth(request, auth, token);
91
92    let resp = request
93        .send()
94        .await
95        .map_err(|e| Error::Http(format!("{provider_label} liveness GET {url}: {e}")))?;
96
97    let status = resp.status();
98    match status.as_u16() {
99        200 => {
100            let body: ConfluenceCurrentUser = resp.json().await.map_err(|e| {
101                Error::InvalidData(format!(
102                    "{provider_label} user/current returned non-JSON body: {e}"
103                ))
104            })?;
105            let detail = body
106                .display_name
107                .or(body.username)
108                .or(body.account_id)
109                .or(body.email)
110                .unwrap_or_else(|| provider_label.to_owned());
111            Ok(LivenessResult::live(detail))
112        }
113        401 => Ok(LivenessResult::revoked(format!(
114            "{provider_label} rejected the token (401 Unauthorized)"
115        ))),
116        403 => Ok(LivenessResult::revoked(format!(
117            "{provider_label} refused the token (403 Forbidden)"
118        ))),
119        429 => {
120            let retry = resp
121                .headers()
122                .get("retry-after")
123                .and_then(|v| v.to_str().ok())
124                .map(str::to_owned)
125                .unwrap_or_else(|| "unknown".to_owned());
126            Ok(LivenessResult::throttled(format!(
127                "{provider_label} rate limit exceeded; retry-after: {retry}"
128            )))
129        }
130        other => {
131            let body = resp.text().await.unwrap_or_default();
132            Ok(LivenessResult::error(format!(
133                "{provider_label} GET {url} returned {other}: {}",
134                body.trim()
135            )))
136        }
137    }
138}
139
140async fn probe_cloud_bearer(
141    http: &reqwest::Client,
142    client: &ConfluenceClient,
143    token: &SecretString,
144) -> Result<LivenessResult> {
145    let accessible_resources_url = format!(
146        "{}/oauth/token/accessible-resources",
147        client
148            .cloud_api_base_url()
149            .unwrap_or_else(|| "https://api.atlassian.com".to_string())
150    );
151    let resp = http
152        .get(&accessible_resources_url)
153        .header(reqwest::header::ACCEPT, "application/json")
154        .bearer_auth(token.expose_secret())
155        .send()
156        .await
157        .map_err(|e| {
158            Error::Http(format!(
159                "confluence cloud liveness GET accessible-resources: {e}"
160            ))
161        })?;
162
163    match resp.status().as_u16() {
164        200 => {
165            let resources: Vec<AtlassianAccessibleResource> = resp.json().await.map_err(|e| {
166                Error::InvalidData(format!(
167                    "confluence cloud accessible-resources returned non-JSON body: {e}"
168                ))
169            })?;
170            let wanted_origin = url_origin(client.instance_url())
171                .or_else(|| url_origin(client.base_url()))
172                .ok_or_else(|| {
173                    Error::InvalidData(format!(
174                        "cannot determine origin from Confluence base URL '{}'",
175                        client.base_url()
176                    ))
177                })?;
178
179            let matched = resources
180                .into_iter()
181                .find(|resource| {
182                    resource
183                        .url
184                        .as_deref()
185                        .and_then(url_origin)
186                        .map(|origin| origin == wanted_origin)
187                        .unwrap_or(false)
188                })
189                .ok_or_else(|| {
190                    Error::NotFound(format!(
191                        "no Atlassian accessible resource matched Confluence base URL '{}'",
192                        client.instance_url()
193                    ))
194                })?;
195
196            let cloud_api_base = client
197                .cloud_api_base_url()
198                .unwrap_or_else(|| "https://api.atlassian.com".to_string());
199            let probe_url = format!(
200                "{cloud_api_base}/ex/confluence/{}/wiki/api/v2/space?limit=1",
201                matched.id
202            );
203            let probe = http
204                .get(&probe_url)
205                .header(reqwest::header::ACCEPT, "application/json")
206                .bearer_auth(token.expose_secret())
207                .send()
208                .await
209                .map_err(|e| {
210                    Error::Http(format!(
211                        "confluence cloud liveness GET /wiki/api/v2/space: {e}"
212                    ))
213                })?;
214
215            match probe.status().as_u16() {
216                200 => Ok(LivenessResult::live(format!(
217                    "{} ({})",
218                    matched.url.as_deref().unwrap_or(client.instance_url()),
219                    matched.id
220                ))),
221                401 => Ok(LivenessResult::revoked(
222                    "confluence cloud rejected the bearer token (401 Unauthorized)",
223                )),
224                403 => Ok(LivenessResult::revoked(
225                    "confluence cloud refused the bearer token (403 Forbidden)",
226                )),
227                429 => {
228                    let retry = probe
229                        .headers()
230                        .get("retry-after")
231                        .and_then(|v| v.to_str().ok())
232                        .map(str::to_owned)
233                        .unwrap_or_else(|| "unknown".to_owned());
234                    Ok(LivenessResult::throttled(format!(
235                        "confluence cloud rate limit exceeded; retry-after: {retry}"
236                    )))
237                }
238                other => {
239                    let body = probe.text().await.unwrap_or_default();
240                    Ok(LivenessResult::error(format!(
241                        "confluence cloud GET {probe_url} returned {other}: {}",
242                        body.trim()
243                    )))
244                }
245            }
246        }
247        401 => Ok(LivenessResult::revoked(
248            "confluence cloud rejected the bearer token (401 Unauthorized)",
249        )),
250        403 => Ok(LivenessResult::revoked(
251            "confluence cloud refused the bearer token (403 Forbidden)",
252        )),
253        429 => {
254            let retry = resp
255                .headers()
256                .get("retry-after")
257                .and_then(|v| v.to_str().ok())
258                .map(str::to_owned)
259                .unwrap_or_else(|| "unknown".to_owned());
260            Ok(LivenessResult::throttled(format!(
261                "confluence cloud rate limit exceeded; retry-after: {retry}"
262            )))
263        }
264        other => {
265            let body = resp.text().await.unwrap_or_default();
266            Ok(LivenessResult::error(format!(
267                "confluence cloud GET {accessible_resources_url} returned {other}: {}",
268                body.trim()
269            )))
270        }
271    }
272}
273
274fn apply_auth(
275    request: reqwest::RequestBuilder,
276    auth: &ConfluenceAuth,
277    token: &SecretString,
278) -> reqwest::RequestBuilder {
279    match auth {
280        ConfluenceAuth::None => request,
281        ConfluenceAuth::BearerToken(_) => request.bearer_auth(token.expose_secret()),
282        ConfluenceAuth::Basic { username, .. } => {
283            request.basic_auth(username.as_str(), Some(token.expose_secret()))
284        }
285    }
286}
287
288fn url_origin(url: &str) -> Option<String> {
289    let (scheme, rest) = url.split_once("://")?;
290    let host = rest.split('/').next()?;
291    if host.is_empty() {
292        return None;
293    }
294    Some(format!("{}://{}", scheme.to_ascii_lowercase(), host))
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use devboy_core::liveness::LivenessStatus;
301    use httpmock::prelude::*;
302
303    #[tokio::test]
304    async fn self_hosted_live_token_returns_user_detail() {
305        let server = MockServer::start_async().await;
306        let _m = server
307            .mock_async(|when, then| {
308                when.method(GET)
309                    .path("/rest/api/user/current")
310                    .header("Authorization", "Bearer secret-token");
311                then.status(200)
312                    .json_body(serde_json::json!({"displayName": "Confluence Admin"}));
313            })
314            .await;
315
316        let client = ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("ignored"));
317        let r = client
318            .test(&SecretString::from("secret-token".to_owned()))
319            .await
320            .unwrap();
321        assert_eq!(r.status, LivenessStatus::Live);
322        assert_eq!(r.detail.as_deref(), Some("Confluence Admin"));
323    }
324
325    #[tokio::test]
326    async fn cloud_basic_live_token_uses_wiki_rest_api() {
327        let server = MockServer::start_async().await;
328        let _m = server
329            .mock_async(|when, then| {
330                when.method(GET).path("/wiki/rest/api/user/current").header(
331                    "Authorization",
332                    "Basic ZGV2QGV4YW1wbGUuY29tOnNlY3JldC10b2tlbg==",
333                );
334                then.status(200)
335                    .json_body(serde_json::json!({"accountId": "acct-123"}));
336            })
337            .await;
338
339        let client = ConfluenceClient::new(
340            server.base_url(),
341            ConfluenceAuth::basic("dev@example.com", "ignored"),
342        )
343        .with_flavor(ConfluenceFlavor::Cloud);
344        let r = client
345            .test(&SecretString::from("secret-token".to_owned()))
346            .await
347            .unwrap();
348        assert_eq!(r.status, LivenessStatus::Live);
349        assert_eq!(r.detail.as_deref(), Some("acct-123"));
350    }
351
352    #[tokio::test]
353    async fn cloud_bearer_live_token_discovers_resource_and_probes_space_api() {
354        let server = MockServer::start_async().await;
355        let _resources = server
356            .mock_async(|when, then| {
357                when.method(GET)
358                    .path("/oauth/token/accessible-resources")
359                    .header("Authorization", "Bearer secret-token");
360                then.status(200).json_body(serde_json::json!([
361                    {"id": "cloud-123", "url": "https://team.atlassian.net"}
362                ]));
363            })
364            .await;
365        let _spaces = server
366            .mock_async(|when, then| {
367                when.method(GET)
368                    .path("/ex/confluence/cloud-123/wiki/api/v2/space")
369                    .query_param("limit", "1")
370                    .header("Authorization", "Bearer secret-token");
371                then.status(200)
372                    .json_body(serde_json::json!({"results": []}));
373            })
374            .await;
375
376        let client = ConfluenceClient::new(
377            "https://team.atlassian.net",
378            ConfluenceAuth::bearer("ignored-self-token"),
379        )
380        .with_flavor(ConfluenceFlavor::Cloud)
381        .with_cloud_api_base_url(server.base_url());
382        let r = client
383            .test(&SecretString::from("secret-token".to_owned()))
384            .await
385            .unwrap();
386        assert_eq!(r.status, LivenessStatus::Live);
387        let detail = r.detail.unwrap();
388        assert!(detail.contains("team.atlassian.net"));
389        assert!(detail.contains("cloud-123"));
390    }
391
392    #[tokio::test]
393    async fn revoked_on_401() {
394        let server = MockServer::start_async().await;
395        let _m = server
396            .mock_async(|when, then| {
397                when.method(GET).path("/rest/api/user/current");
398                then.status(401);
399            })
400            .await;
401        let client = ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("ignored"));
402        let r = client
403            .test(&SecretString::from("bad".to_owned()))
404            .await
405            .unwrap();
406        assert_eq!(r.status, LivenessStatus::Revoked);
407    }
408
409    #[tokio::test]
410    async fn throttled_on_429_carries_retry_after() {
411        let server = MockServer::start_async().await;
412        let _m = server
413            .mock_async(|when, then| {
414                when.method(GET).path("/rest/api/user/current");
415                then.status(429).header("Retry-After", "60");
416            })
417            .await;
418        let client = ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("ignored"));
419        let r = client
420            .test(&SecretString::from("over-limit".to_owned()))
421            .await
422            .unwrap();
423        assert_eq!(r.status, LivenessStatus::Throttled);
424        assert!(r.detail.unwrap().contains("60"));
425    }
426
427    #[test]
428    fn provider_name_is_confluence() {
429        let client =
430            ConfluenceClient::new("https://example.atlassian.net", ConfluenceAuth::bearer("x"));
431        assert_eq!(client.provider_name(), "confluence");
432    }
433}