Skip to main content

qcs_api_client_common/configuration/
settings.rs

1//! Models and utilities for managing QCS settings.
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5use figment::providers::Format;
6use figment::{Figment, providers::Toml};
7use serde::{Deserialize, Serialize};
8
9#[cfg(feature = "stubs")]
10use pyo3_stub_gen::derive::gen_stub_pyclass;
11
12use crate::configuration::error::DiscoveryError;
13use crate::configuration::oidc::{DISCOVERY_REQUIRED_SCOPE, fetch_discovery};
14use crate::configuration::tokens::default_http_client;
15
16use super::{
17    DEFAULT_API_URL, DEFAULT_GRPC_API_URL, DEFAULT_PROFILE_NAME, DEFAULT_QUILC_URL,
18    DEFAULT_QVM_URL, LoadError, env_or_default_quilc_url, env_or_default_qvm_url,
19    expand_path_from_env_or_default,
20};
21
22/// The scopes requested during an interactive login when an [`AuthServer`] does not configure any.
23///
24/// This is deliberately a fixed list of the minimal set of scopes needed to make auth convenient,
25/// particularly allowing for sessions to automatically refresh via refresh token (`offline_access`).
26///
27/// Blanketly requesting every `scopes_supported` field of the issuer's discovery document would
28/// cause errors, as not all clients/methods support all scopes. Anything beyond this should
29/// be requested explicitly via [`AuthServer::scopes`].
30pub const DEFAULT_LOGIN_SCOPES: [&str; 4] = [
31    DISCOVERY_REQUIRED_SCOPE,
32    "profile",
33    "email",
34    "offline_access",
35];
36
37/// Setting the `QCS_SETTINGS_FILE_PATH` environment variable will change which file is used for loading [`Settings`].
38pub const SETTINGS_PATH_VAR: &str = "QCS_SETTINGS_FILE_PATH";
39/// The default path that [`Settings`] will be loaded from;
40pub const DEFAULT_SETTINGS_PATH: &str = "~/.qcs/settings.toml";
41
42/// The structure of QCS settings, typically serialized as a TOML file at [`DEFAULT_SETTINGS_PATH`].
43#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
44pub struct Settings {
45    /// The default profile to use - this should match a key of [`Settings::profiles`].
46    #[serde(default = "default_profile_name")]
47    pub default_profile_name: String,
48
49    /// All named [`Profile`]s defined in the settings file.
50    #[serde(default = "default_profiles")]
51    pub profiles: HashMap<String, Profile>,
52
53    /// All named [`AuthServer`]s defined in the settings file.
54    #[serde(default = "default_auth_servers")]
55    pub auth_servers: HashMap<String, AuthServer>,
56
57    /// The path to the settings file this [`Settings`] was loaded from,
58    /// if it was loaded from a file. This is not stored in the settings file itself.
59    #[serde(skip)]
60    pub file_path: Option<PathBuf>,
61}
62
63impl Settings {
64    /// Load [`Settings`] from the path specified by the [`SETTINGS_PATH_VAR`] environment variable if set,
65    /// or else the default path at [`DEFAULT_SETTINGS_PATH`].
66    ///
67    /// # Errors
68    ///
69    /// [`LoadError`] if the settings file cannot be loaded.
70    pub fn load() -> Result<Self, LoadError> {
71        let path = expand_path_from_env_or_default(SETTINGS_PATH_VAR, DEFAULT_SETTINGS_PATH)?;
72        #[cfg(feature = "tracing")]
73        tracing::debug!("loading QCS settings from {path:?}");
74        Self::load_from_path(&path)
75    }
76
77    /// Load [`Settings`] from the path specified by `path`.
78    ///
79    /// # Errors
80    ///
81    /// [`LoadError`] if the settings file cannot be loaded.
82    pub fn load_from_path(path: &PathBuf) -> Result<Self, LoadError> {
83        let mut settings: Self = Figment::from(Toml::file(path)).extract()?;
84        settings.file_path = Some(path.into());
85        Ok(settings)
86    }
87}
88
89impl Default for Settings {
90    fn default() -> Self {
91        Self {
92            default_profile_name: default_profile_name(),
93            profiles: default_profiles(),
94            auth_servers: default_auth_servers(),
95            file_path: None,
96        }
97    }
98}
99
100fn default_profile_name() -> String {
101    DEFAULT_PROFILE_NAME.to_string()
102}
103
104fn default_profiles() -> HashMap<String, Profile> {
105    HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), Profile::default())])
106}
107
108fn default_auth_servers() -> HashMap<String, AuthServer> {
109    HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), AuthServer::default())])
110}
111
112/// A particular profile of [`Settings`], which defines all the configurable options
113/// for connecting to a particular QCS instance using a particular set of credentials.
114#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
115pub struct Profile {
116    /// URL of the QCS REST API.
117    #[serde(default = "default_api_url")]
118    pub api_url: String,
119    /// URL of the QCS gRPC API.
120    #[serde(default = "default_grpc_api_url")]
121    pub grpc_api_url: String,
122    /// Name of the [`AuthServer`] to use.
123    #[serde(default = "default_profile_name")]
124    pub auth_server_name: String,
125    /// Name of the [`Credential`][`super::secrets::Credential`] to use from the corresponding [`Secrets`][`super::secrets::Secrets`].
126    #[serde(default = "default_profile_name")]
127    pub credentials_name: String,
128    /// Application specific settings.
129    #[serde(default)]
130    pub applications: Applications,
131}
132
133impl Default for Profile {
134    fn default() -> Self {
135        Self {
136            api_url: DEFAULT_API_URL.to_string(),
137            grpc_api_url: DEFAULT_GRPC_API_URL.to_string(),
138            auth_server_name: DEFAULT_PROFILE_NAME.to_string(),
139            credentials_name: DEFAULT_PROFILE_NAME.to_string(),
140            applications: Applications::default(),
141        }
142    }
143}
144
145fn default_api_url() -> String {
146    DEFAULT_API_URL.to_string()
147}
148
149fn default_grpc_api_url() -> String {
150    DEFAULT_GRPC_API_URL.to_string()
151}
152
153pub(crate) const QCS_DEFAULT_CLIENT_ID_PRODUCTION: &str = "0oa3ykoirzDKpkfzk357";
154pub(crate) const QCS_DEFAULT_AUTH_ISSUER_PRODUCTION: &str =
155    "https://auth.qcs.rigetti.com/oauth2/aus8jcovzG0gW2TUG355";
156
157/// OAuth 2.0 authorization server.
158#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
159#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
160#[cfg_attr(
161    feature = "python",
162    pyo3::pyclass(
163        module = "qcs_api_client_common._qcs_api_client_common.configuration",
164        eq,
165        get_all,
166        set_all,
167        from_py_object
168    )
169)]
170pub struct AuthServer {
171    /// OAuth 2.0 client id.
172    pub client_id: String,
173    /// OAuth 2.0 issuer URL.
174    ///
175    /// This is the base URL of the identity provider.
176    /// For Okta, this usually looks like `https://example.okta.com/oauth2/default`.
177    /// For Cognito, it might look like `https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example`.
178    ///
179    /// Note that this is technically distinct from the `issuer` field in [`OidcDiscovery`],
180    /// which is the canonical URI that the identity provider uses to sign and validate tokens,
181    /// but the OpenID specification requires that they match exactly,
182    /// and that they match the `iss` claim in Tokens issued by this identity provider.
183    pub issuer: String,
184
185    /// OAuth 2.0 scopes to request during authorization requests.
186    /// If not specified, [`DEFAULT_LOGIN_SCOPES`] will be used.
187    /// The scope `openid` is always requested, even if not present in this list.
188    pub scopes: Option<Vec<String>>,
189}
190
191impl Default for AuthServer {
192    fn default() -> Self {
193        Self {
194            client_id: QCS_DEFAULT_CLIENT_ID_PRODUCTION.to_string(),
195            issuer: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
196            scopes: None,
197        }
198    }
199}
200
201impl AuthServer {
202    /// Create a new [`AuthServer`] with a `client_id` and `issuer` and an optional list of scopes.
203    ///
204    /// If `scopes` is [`None`], [`DEFAULT_LOGIN_SCOPES`] will be used when requesting authorization tokens.
205    /// Note that the required scope `openid` is always requested, even if `scopes` is provided but does not contain it.
206    #[must_use]
207    pub const fn new(client_id: String, issuer: String, scopes: Option<Vec<String>>) -> Self {
208        Self {
209            client_id,
210            issuer,
211            scopes,
212        }
213    }
214
215    /// Create a new [`AuthServer`] with the specified `client_id` and `issuer`,
216    /// populating `scopes` with all `scopes_supported` fetched from the issuer's discovery document.
217    ///
218    /// Note that the advertised set is generally broader than a login requires, and some providers
219    /// reject a request for all of them. See the comment on [`DEFAULT_LOGIN_SCOPES`].
220    ///
221    /// # Errors
222    /// Returns an error if the discovery document cannot be fetched or parsed.
223    pub async fn new_with_discovery_supported_scopes(
224        client_id: String,
225        issuer: String,
226    ) -> Result<Self, DiscoveryError> {
227        let client = default_http_client()?;
228        let discovery = fetch_discovery(&client, &issuer).await?;
229        Ok(Self {
230            client_id,
231            issuer,
232            scopes: Some(discovery.scopes_supported),
233        })
234    }
235}
236
237/// Settings for secondary applications used by QCS SDKs.
238#[derive(Deserialize, Clone, Debug, Default, PartialEq, Eq, Serialize)]
239pub struct Applications {
240    /// Settings for use of the pyquil SDK.
241    #[serde(default)]
242    pub pyquil: Pyquil,
243}
244
245/// Settings for secondary applications used by pyquil.
246#[derive(Deserialize, Clone, Debug, PartialEq, Eq, Serialize)]
247pub struct Pyquil {
248    /// URL of the QVM server.
249    #[serde(default = "env_or_default_qvm_url")]
250    pub qvm_url: String,
251
252    /// URL of the Quilc compiler server.
253    #[serde(default = "env_or_default_quilc_url")]
254    pub quilc_url: String,
255}
256
257impl Default for Pyquil {
258    fn default() -> Self {
259        Self {
260            quilc_url: DEFAULT_QUILC_URL.to_string(),
261            qvm_url: DEFAULT_QVM_URL.to_string(),
262        }
263    }
264}
265
266#[cfg(test)]
267mod test {
268    #![allow(clippy::result_large_err, reason = "happens in figment tests")]
269
270    use std::path::PathBuf;
271
272    use super::{SETTINGS_PATH_VAR, Settings};
273
274    #[test]
275    fn returns_err_if_invalid_path_env() {
276        figment::Jail::expect_with(|jail| {
277            jail.set_env(SETTINGS_PATH_VAR, "/blah/doesnt_exist.toml");
278            Settings::load().expect_err("Should return error when a file cannot be found.");
279            Ok(())
280        });
281    }
282
283    #[test]
284    fn test_uses_defaults_incomplete_settings() {
285        figment::Jail::expect_with(|jail| {
286            let _ = jail.create_file("settings.toml", r#"default_profile_name = "TEST""#)?;
287            jail.set_env(SETTINGS_PATH_VAR, "settings.toml");
288            let loaded = Settings::load().expect("should load settings");
289            let expected = Settings {
290                default_profile_name: "TEST".to_string(),
291                file_path: Some(PathBuf::from("settings.toml")),
292                ..Settings::default()
293            };
294
295            assert_eq!(loaded, expected);
296
297            Ok(())
298        });
299    }
300
301    #[test]
302    fn loads_from_env_var_path() {
303        figment::Jail::expect_with(|jail| {
304            let settings = Settings {
305                default_profile_name: "TEST".to_string(),
306                file_path: Some(PathBuf::from("secrets.toml")),
307                ..Settings::default()
308            };
309            let settings_string =
310                toml::to_string(&settings).expect("Should be able to serialize settings");
311
312            _ = jail.create_file("secrets.toml", &settings_string)?;
313            jail.set_env(SETTINGS_PATH_VAR, "secrets.toml");
314
315            assert_eq!(settings, Settings::load().unwrap());
316
317            Ok(())
318        });
319    }
320}