Skip to main content

bitwarden_core/client/
flags_client.rs

1//! Feature flag retrieval, persistence, and refresh from the server `/config` endpoint.
2
3use std::{collections::HashMap, sync::Arc};
4
5use bitwarden_state::Setting;
6use chrono::{DateTime, Duration, Utc};
7
8use crate::{
9    Client,
10    client::{
11        flags::Flags,
12        internal::ApiConfigurations,
13        persisted_state::{FLAGS, FLAGS_FETCHED_AT},
14    },
15};
16
17const FLAGS_TTL: Duration = Duration::hours(1);
18
19/// Errors returned by [`FlagsClient::fetch`].
20#[derive(Debug, thiserror::Error)]
21pub enum FetchFlagsError {
22    /// Network or deserialization error when fetching `/config`.
23    #[error("failed to fetch /config: {0}")]
24    Api(#[from] bitwarden_api_api::ApiError),
25    /// Error persisting flags or fetched_at timestamp to state registry.
26    #[error("state access error: {0}")]
27    State(#[from] bitwarden_state::SettingsError),
28}
29
30/// A client for inspecting and refreshing feature flags.
31#[bitwarden_ffi::wasm_object]
32pub struct FlagsClient {
33    flags: Setting<Flags>,
34    flags_fetched_at: Setting<DateTime<Utc>>,
35    api_configurations: Arc<ApiConfigurations>,
36}
37
38impl FlagsClient {
39    /// Persist a flag map (e.g. from `/config`) into the state registry.
40    pub async fn load(&self, flags: HashMap<String, bool>) {
41        let flags = Flags::load_from_map(flags);
42        if let Err(e) = self.flags.update(flags).await {
43            tracing::warn!("Failed to persist flags: {e}");
44        }
45    }
46
47    /// Retrieve the active feature flags from the state registry.
48    pub async fn get(&self) -> Flags {
49        match self.flags.get().await {
50            Ok(flags) => flags.unwrap_or_default(),
51            Err(e) => {
52                tracing::warn!("Failed to read flags, using defaults: {e}");
53                Flags::default()
54            }
55        }
56    }
57
58    /// Fetch flags from `/config` and persist both the flag values and a `fetched_at` timestamp.
59    ///
60    /// Pass `force = true` from `from_authenticated_data` (PM-27624) immediately before
61    /// `save_to_state`, so the initial flag fetch is part of the persisted login state.
62    /// [`Client::load_from_state`] calls this with `force = false` to honour the 1-hour TTL.
63    pub async fn fetch(&self, force: bool) -> Result<(), FetchFlagsError> {
64        if !force {
65            let last: Option<DateTime<Utc>> = self.flags_fetched_at.get().await?;
66            if let Some(fetched_at) = last
67                && Utc::now().signed_duration_since(fetched_at) < FLAGS_TTL
68            {
69                return Ok(());
70            }
71        }
72
73        let config = self
74            .api_configurations
75            .api_client
76            .config_api()
77            .get_configs()
78            .await?;
79        let feature_states = config.feature_states.unwrap_or_default();
80        // `/config` returns `serde_json::Value`; coerce to bool. Non-bool values are dropped
81        // because `Flags` only models boolean flags today.
82        let bool_map = feature_states
83            .into_iter()
84            .filter_map(|(k, v)| v.as_bool().map(|b| (k, b)))
85            .collect();
86        self.load(bool_map).await;
87        self.flags_fetched_at.update(Utc::now()).await?;
88        Ok(())
89    }
90}
91
92impl Client {
93    /// Access to feature flag retrieval, persistence, and refresh.
94    pub fn flags(&self) -> FlagsClient {
95        let registry = &self.internal.state_registry;
96        FlagsClient {
97            flags: registry
98                .setting(FLAGS)
99                .expect("Settings repository must be registered on the state registry"),
100            flags_fetched_at: registry
101                .setting(FLAGS_FETCHED_AT)
102                .expect("Settings repository must be registered on the state registry"),
103            api_configurations: self.internal.api_configurations.clone(),
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use serde_json::json;
111    use wiremock::{
112        Mock, MockServer, ResponseTemplate,
113        matchers::{method, path},
114    };
115
116    use super::*;
117    use crate::{ClientSettings, DeviceType};
118
119    fn settings_for(server: &MockServer) -> ClientSettings {
120        ClientSettings {
121            identity_url: format!("http://{}", server.address()),
122            api_url: format!("http://{}", server.address()),
123            user_agent: "flags-tests".to_string(),
124            device_type: DeviceType::SDK,
125            device_identifier: None,
126            bitwarden_client_version: None,
127            bitwarden_package_type: None,
128        }
129    }
130
131    async fn write_fetched_at(client: &Client, at: DateTime<Utc>) {
132        client
133            .internal
134            .state_registry
135            .setting(FLAGS_FETCHED_AT)
136            .unwrap()
137            .update(at)
138            .await
139            .unwrap();
140    }
141
142    async fn read_fetched_at(client: &Client) -> Option<DateTime<Utc>> {
143        client
144            .internal
145            .state_registry
146            .setting(FLAGS_FETCHED_AT)
147            .unwrap()
148            .get()
149            .await
150            .unwrap()
151    }
152
153    #[tokio::test]
154    async fn load_round_trips_through_setting() {
155        let client = Client::new(None);
156
157        // With no flags loaded yet, get should return defaults.
158        let initial = client.flags().get().await;
159        assert!(!initial.strict_cipher_decryption);
160
161        // Loading flags should persist them via the FLAGS setting.
162        let mut map = HashMap::new();
163        map.insert("pm-34500-strict-cipher-decryption".to_string(), true);
164        client.flags().load(map).await;
165
166        // get should now return the loaded values.
167        let loaded = client.flags().get().await;
168        assert!(loaded.strict_cipher_decryption);
169
170        // The values should be readable directly from the setting too.
171        let persisted = client
172            .internal
173            .state_registry
174            .setting(FLAGS)
175            .unwrap()
176            .get()
177            .await
178            .unwrap()
179            .expect("flags should be persisted after load");
180        assert!(persisted.strict_cipher_decryption);
181    }
182
183    #[tokio::test]
184    async fn fetch_force_persists_flags_and_timestamp() {
185        let server = MockServer::start().await;
186        Mock::given(method("GET"))
187            .and(path("/config"))
188            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
189                "featureStates": { "pm-34500-strict-cipher-decryption": true }
190            })))
191            .expect(1)
192            .mount(&server)
193            .await;
194
195        let client = Client::new(Some(settings_for(&server)));
196        let before = Utc::now();
197        client.flags().fetch(true).await.unwrap();
198
199        assert!(client.flags().get().await.strict_cipher_decryption);
200        let fetched_at = read_fetched_at(&client)
201            .await
202            .expect("fetched_at must be set after a successful fetch");
203        assert!(fetched_at >= before);
204    }
205
206    #[tokio::test]
207    async fn fetch_skips_when_fresh() {
208        let server = MockServer::start().await;
209        Mock::given(method("GET"))
210            .and(path("/config"))
211            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
212            .expect(0)
213            .mount(&server)
214            .await;
215
216        let client = Client::new(Some(settings_for(&server)));
217        write_fetched_at(&client, Utc::now() - Duration::minutes(5)).await;
218
219        client.flags().fetch(false).await.unwrap();
220    }
221
222    #[tokio::test]
223    async fn fetch_force_ignores_ttl() {
224        let server = MockServer::start().await;
225        Mock::given(method("GET"))
226            .and(path("/config"))
227            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
228            .expect(1)
229            .mount(&server)
230            .await;
231
232        let client = Client::new(Some(settings_for(&server)));
233        write_fetched_at(&client, Utc::now() - Duration::minutes(5)).await;
234
235        client.flags().fetch(true).await.unwrap();
236    }
237
238    #[tokio::test]
239    async fn fetch_refreshes_when_stale() {
240        let server = MockServer::start().await;
241        Mock::given(method("GET"))
242            .and(path("/config"))
243            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
244                "featureStates": { "pm-34500-strict-cipher-decryption": true }
245            })))
246            .expect(1)
247            .mount(&server)
248            .await;
249
250        let client = Client::new(Some(settings_for(&server)));
251        let stale = Utc::now() - Duration::hours(2);
252        write_fetched_at(&client, stale).await;
253
254        client.flags().fetch(false).await.unwrap();
255
256        assert!(client.flags().get().await.strict_cipher_decryption);
257        let fetched_at = read_fetched_at(&client).await.unwrap();
258        assert!(fetched_at > stale);
259    }
260
261    #[tokio::test]
262    async fn fetch_network_error_is_non_fatal_and_preserves_flags() {
263        let server = MockServer::start().await;
264        Mock::given(method("GET"))
265            .and(path("/config"))
266            .respond_with(ResponseTemplate::new(500))
267            .mount(&server)
268            .await;
269
270        let client = Client::new(Some(settings_for(&server)));
271        client
272            .flags()
273            .load(HashMap::from([(
274                "pm-34500-strict-cipher-decryption".to_string(),
275                true,
276            )]))
277            .await;
278
279        assert!(client.flags().fetch(true).await.is_err());
280        assert!(
281            client.flags().get().await.strict_cipher_decryption,
282            "previously persisted flags must survive a failed fetch"
283        );
284    }
285}