1use anyhow::Context;
2use secrecy::{ExposeSecret, SecretBox, SecretString};
3use std::sync::Arc;
4use std::{env, str::FromStr};
5use url::Url;
6
7pub fn bool_env_false_by_default(key: &str) -> bool {
9 match env::var(key) {
10 Ok(value) => {
11 let normalized = value.trim().to_ascii_lowercase();
12 !matches!(
13 normalized.as_str(),
14 "" | "false" | "0" | "no" | "off" | "disabled"
15 )
16 }
17 Err(_) => false,
18 }
19}
20
21fn non_empty_env(key: &str) -> Option<String> {
23 match env::var(key) {
24 Ok(value) if !value.trim().is_empty() => Some(value.trim().to_string()),
25 _ => None,
26 }
27}
28
29fn i64_env_or(key: &str, default: i64) -> i64 {
31 non_empty_env(key)
32 .and_then(|value| value.parse().ok())
33 .unwrap_or(default)
34}
35
36fn parse_join_base(url: &str) -> Result<Url, url::ParseError> {
43 let mut url = Url::parse(url)?;
44 if !url.path().ends_with('/') {
45 url.set_path(&format!("{}/", url.path()));
46 }
47 Ok(url)
48}
49
50#[derive(Clone)]
51pub struct ApplicationConfiguration {
52 pub base_url: String,
53 pub test_mode: bool,
54 pub test_chatbot: bool,
55 pub test_sisu: bool,
56 pub test_suotar: bool,
57 pub disable_embedding_vector_creation_when_seeding: bool,
58 pub development_uuid_login: bool,
59 pub enable_admin_email_verification: bool,
60 pub enable_email_ownership_verification: bool,
61 pub azure_configuration: Option<AzureConfiguration>,
62 pub suotar_configuration: SuotarConfiguration,
63 pub tmc_account_creation_origin: Option<String>,
64 pub tmc_admin_access_token: SecretString,
65 pub oauth_server_configuration: OAuthServerConfiguration,
66}
67
68impl ApplicationConfiguration {
69 pub fn try_from_env() -> anyhow::Result<Self> {
71 let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
72 let test_mode = bool_env_false_by_default("TEST_MODE");
73 let development_uuid_login = bool_env_false_by_default("DEVELOPMENT_UUID_LOGIN");
74 let enable_admin_email_verification =
75 bool_env_false_by_default("ENABLE_ADMIN_EMAIL_VERIFICATION");
76 let enable_email_ownership_verification =
77 bool_env_false_by_default("ENABLE_EMAIL_OWNERSHIP_VERIFICATION");
78 let test_chatbot = test_mode
79 && (bool_env_false_by_default("USE_MOCK_AZURE_CONFIGURATION")
80 || env::var("AZURE_CHATBOT_API_KEY").is_err());
81
82 let test_sisu = test_mode && bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
83
84 let test_suotar = test_mode && bool_env_false_by_default("USE_MOCK_SUOTAR_ENDPOINT");
86
87 let disable_embedding_vector_creation_when_seeding = false;
88
89 let azure_configuration = if test_chatbot {
90 AzureConfiguration::mock_conf()?
91 } else {
92 AzureConfiguration::try_from_env()?
93 };
94
95 let suotar_configuration = if test_suotar {
96 SuotarConfiguration::mock_conf(&base_url)?
97 } else {
98 SuotarConfiguration::try_from_env()?
99 };
100
101 let tmc_account_creation_origin = Some(
102 env::var("TMC_ACCOUNT_CREATION_ORIGIN")
103 .context("TMC_ACCOUNT_CREATION_ORIGIN must be defined")?,
104 );
105
106 let tmc_admin_access_token = SecretString::new(
107 std::env::var("TMC_ACCESS_TOKEN")
108 .unwrap_or_else(|_| {
109 if test_mode {
110 "mock-access-token".to_string()
111 } else {
112 panic!("TMC_ACCESS_TOKEN must be defined in production")
113 }
114 })
115 .into(),
116 );
117 let oauth_server_configuration = OAuthServerConfiguration::try_from_env()
118 .context("Failed to load OAuth server configuration")?;
119
120 Ok(Self {
121 base_url,
122 test_mode,
123 test_chatbot,
124 test_sisu,
125 test_suotar,
126 disable_embedding_vector_creation_when_seeding,
127 development_uuid_login,
128 enable_admin_email_verification,
129 enable_email_ownership_verification,
130 azure_configuration,
131 suotar_configuration,
132 tmc_account_creation_origin,
133 tmc_admin_access_token,
134 oauth_server_configuration,
135 })
136 }
137
138 pub fn mock_conf() -> anyhow::Result<Self> {
139 let test_mode = true;
140 let base_url = "http://project-331.local/".to_string();
141 let development_uuid_login = false;
142 let enable_admin_email_verification = false;
143 let enable_email_ownership_verification = false;
144 let azure_configuration = AzureConfiguration::mock_conf()?;
145 let test_chatbot = true;
146 let test_sisu = true;
147 let test_suotar = false;
148 let disable_embedding_vector_creation_when_seeding = true;
149 let suotar_configuration = SuotarConfiguration::mock_conf("http://project-331.local")
150 .expect("Failed to build the mock Suotar configuration");
151 let tmc_account_creation_origin = None;
152 let tmc_admin_access_token = SecretString::new("mock-access-token".to_string().into());
153 let oauth_server_configuration = OAuthServerConfiguration {
154 rsa_public_key: "temp-change-when-needed".into(),
155 rsa_private_key: SecretString::new("test-change".into()),
156 oauth_token_hmac_key: SecretString::new("pippuri".into()),
157 dpop_nonce_key: std::sync::Arc::new(secrecy::SecretBox::new(Box::new(
158 "test-key".into(),
159 ))),
160 };
161 Ok(Self {
162 base_url,
163 test_mode,
164 test_chatbot,
165 test_sisu,
166 test_suotar,
167 disable_embedding_vector_creation_when_seeding,
168 development_uuid_login,
169 enable_admin_email_verification,
170 enable_email_ownership_verification,
171 azure_configuration,
172 suotar_configuration,
173 tmc_account_creation_origin,
174 tmc_admin_access_token,
175 oauth_server_configuration,
176 })
177 }
178}
179
180pub const SUOTAR_AUTH_SCHEME: &str = "Basic";
183
184pub const MOCK_SUOTAR_TOKEN: &str = "mock-suotar-token";
186
187const FAST_TRACK_EMAIL_MATCH_ENABLED_DEFAULT: bool = false;
190
191const FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS_DEFAULT: i64 = 365;
194
195#[derive(Clone)]
196pub struct SuotarConfiguration {
197 pub api_base_url: Url,
199 pub api_token: SecretString,
200 pub fast_track_email_match_enabled: bool,
201 pub fast_track_max_email_verification_age_days: i64,
202}
203
204impl SuotarConfiguration {
205 pub fn mock_conf(base_url: &str) -> anyhow::Result<Self> {
208 Ok(Self {
209 api_base_url: Url::parse(base_url)
210 .context("Invalid URL in BASE_URL")?
211 .join("/api/v0/mock-suotar/")?,
212 api_token: SecretString::new(MOCK_SUOTAR_TOKEN.to_string().into()),
213 fast_track_email_match_enabled: Self::fast_track_enabled_from_env(),
214 fast_track_max_email_verification_age_days: Self::fast_track_max_age_from_env(),
215 })
216 }
217
218 pub fn try_from_env() -> anyhow::Result<Self> {
219 Self::from_values(
220 non_empty_env("SUOTAR_API_BASE_URL"),
221 non_empty_env("SUOTAR_API_KEY"),
222 Self::fast_track_enabled_from_env(),
223 Self::fast_track_max_age_from_env(),
224 )
225 }
226
227 fn fast_track_enabled_from_env() -> bool {
228 match non_empty_env("SUOTAR_FAST_TRACK_EMAIL_MATCH_ENABLED") {
229 Some(_) => bool_env_false_by_default("SUOTAR_FAST_TRACK_EMAIL_MATCH_ENABLED"),
230 None => FAST_TRACK_EMAIL_MATCH_ENABLED_DEFAULT,
231 }
232 }
233
234 fn fast_track_max_age_from_env() -> i64 {
235 i64_env_or(
236 "SUOTAR_FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS",
237 FAST_TRACK_MAX_EMAIL_VERIFICATION_AGE_DAYS_DEFAULT,
238 )
239 }
240
241 fn from_values(
243 api_base_url: Option<String>,
244 api_token: Option<String>,
245 fast_track_email_match_enabled: bool,
246 fast_track_max_email_verification_age_days: i64,
247 ) -> anyhow::Result<Self> {
248 let api_base_url = api_base_url.context(
249 "SUOTAR_API_BASE_URL must be defined unless TEST_MODE and USE_MOCK_SUOTAR_ENDPOINT are both on. Credit registration writes to the real student registry, so there is no mock fallback.",
250 )?;
251 let api_token = api_token.context(
252 "SUOTAR_API_KEY must be defined unless TEST_MODE and USE_MOCK_SUOTAR_ENDPOINT are both on. Credit registration writes to the real student registry, so there is no mock fallback.",
253 )?;
254 Ok(Self {
255 api_base_url: parse_join_base(&api_base_url)
256 .context("Invalid URL in SUOTAR_API_BASE_URL")?,
257 api_token: SecretString::new(api_token.into()),
258 fast_track_email_match_enabled,
259 fast_track_max_email_verification_age_days,
260 })
261 }
262}
263
264#[derive(Clone)]
265pub struct AzureChatbotConfiguration {
266 pub api_key: SecretString,
267 pub api_base: Url,
269 pub project_name: String,
270}
271
272impl AzureChatbotConfiguration {
273 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
280 let api_key = env::var("AZURE_CHATBOT_API_KEY").ok();
281 let api_endpoint = env::var("AZURE_CHATBOT_API_ENDPOINT").ok();
282 let project_name = env::var("AZURE_PROJECT_NAME").ok();
283
284 if let (Some(api_key), Some(api_endpoint), Some(project_name)) =
285 (api_key, api_endpoint, project_name)
286 {
287 Ok(Some(Self::from_values(
288 api_key,
289 &api_endpoint,
290 project_name,
291 )?))
292 } else {
293 Ok(None)
294 }
295 }
296
297 fn from_values(
299 api_key: String,
300 api_endpoint: &str,
301 project_name: String,
302 ) -> anyhow::Result<Self> {
303 Ok(Self {
304 api_key: SecretString::new(api_key.into()),
305 api_base: parse_join_base(api_endpoint)
306 .context("Invalid URL in AZURE_CHATBOT_API_ENDPOINT")?,
307 project_name,
308 })
309 }
310
311 pub fn responses_endpoint(&self) -> anyhow::Result<Url> {
312 Ok(self.api_base.join(&format!(
313 "api/projects/{}/openai/v1/responses",
314 self.project_name
315 ))?)
316 }
317
318 pub fn embeddings_endpoint(&self) -> anyhow::Result<Url> {
319 Ok(self.api_base.join("openai/v1/embeddings")?)
320 }
321}
322
323#[derive(Clone)]
324pub struct AzureSearchConfiguration {
325 pub vectorizer_resource_uri: String,
326 pub vectorizer_deployment_id: String,
327 pub vectorizer_api_key: SecretString,
328 pub vectorizer_model_name: String,
329 pub search_endpoint: Url,
330 pub search_api_key: SecretString,
331 pub search_connection_id: String,
332}
333
334impl AzureSearchConfiguration {
335 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
340 let vectorizer_resource_uri = env::var("AZURE_VECTORIZER_RESOURCE_URI").ok();
341 let vectorizer_deployment_id = env::var("AZURE_VECTORIZER_DEPLOYMENT_ID").ok();
342 let vectorizer_api_key = env::var("AZURE_VECTORIZER_API_KEY").ok();
343 let vectorizer_model_name = env::var("AZURE_VECTORIZER_MODEL_NAME").ok();
344 let search_endpoint_str = env::var("AZURE_SEARCH_ENDPOINT").ok();
345 let search_api_key = env::var("AZURE_SEARCH_API_KEY").ok();
346 let search_connection_id = env::var("AZURE_SEARCH_CONNECTION_ID").ok();
347
348 if let (
349 Some(vectorizer_resource_uri),
350 Some(vectorizer_deployment_id),
351 Some(vectorizer_api_key),
352 Some(vectorizer_model_name),
353 Some(search_endpoint_str),
354 Some(search_api_key),
355 Some(search_connection_id),
356 ) = (
357 vectorizer_resource_uri,
358 vectorizer_deployment_id,
359 vectorizer_api_key,
360 vectorizer_model_name,
361 search_endpoint_str,
362 search_api_key,
363 search_connection_id,
364 ) {
365 let search_endpoint =
366 Url::parse(&search_endpoint_str).context("Invalid URL in AZURE_SEARCH_ENDPOINT")?;
367 Ok(Some(AzureSearchConfiguration {
368 vectorizer_resource_uri,
369 vectorizer_deployment_id,
370 vectorizer_api_key: SecretString::new(vectorizer_api_key.into()),
371 vectorizer_model_name,
372 search_endpoint,
373 search_api_key: SecretString::new(search_api_key.into()),
374 search_connection_id,
375 }))
376 } else {
377 Ok(None)
378 }
379 }
380}
381
382#[derive(Clone)]
383pub struct AzureBlobStorageConfiguration {
384 pub storage_account: String,
385 pub access_key: SecretString,
386}
387
388impl AzureBlobStorageConfiguration {
389 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
394 let storage_account = env::var("AZURE_BLOB_STORAGE_ACCOUNT").ok();
395 let access_key = env::var("AZURE_BLOB_STORAGE_ACCESS_KEY").ok();
396
397 if let (Some(storage_account), Some(access_key)) = (storage_account, access_key) {
398 Ok(Some(AzureBlobStorageConfiguration {
399 storage_account,
400 access_key: SecretString::new(access_key.into()),
401 }))
402 } else {
403 Ok(None)
404 }
405 }
406
407 pub fn connection_string(&self) -> anyhow::Result<SecretString> {
412 Ok(SecretString::new(
413 format!(
414 "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
415 self.storage_account,
416 self.access_key.expose_secret()
417 )
418 .into(),
419 ))
420 }
421}
422
423#[derive(Clone)]
424pub struct AzureConfiguration {
425 pub chatbot_config: Option<AzureChatbotConfiguration>,
426 pub search_config: Option<AzureSearchConfiguration>,
427 pub blob_storage_config: Option<AzureBlobStorageConfiguration>,
428}
429
430impl AzureConfiguration {
431 pub fn try_from_env() -> anyhow::Result<Option<Self>> {
435 let chatbot = AzureChatbotConfiguration::try_from_env()?;
436 let search_config = AzureSearchConfiguration::try_from_env()?;
437 let blob_storage_config = AzureBlobStorageConfiguration::try_from_env()?;
438 if chatbot.is_some() || search_config.is_some() || blob_storage_config.is_some() {
439 Ok(Some(AzureConfiguration {
440 chatbot_config: chatbot,
441 search_config,
442 blob_storage_config,
443 }))
444 } else {
445 Ok(None)
446 }
447 }
448
449 pub fn mock_conf() -> anyhow::Result<Option<Self>> {
454 let base_url =
455 env::var("BASE_URL").unwrap_or_else(|_| "http://project-331.local/".to_string());
456 let chatbot_config = Some(AzureChatbotConfiguration {
457 api_key: SecretString::new(String::new().into()),
458 api_base: Url::parse(&base_url)?.join("/api/v0/mock-azure/")?,
459 project_name: String::from("test"),
460 });
461
462 let search_config = Some(AzureSearchConfiguration {
463 vectorizer_resource_uri: "".to_string(),
464 vectorizer_deployment_id: "".to_string(),
465 vectorizer_api_key: SecretString::new(String::new().into()),
466 vectorizer_model_name: "".to_string(),
467 search_api_key: SecretString::new(String::new().into()),
468 search_endpoint: Url::from_str("https://example.com/does-not-exist/")?,
469 search_connection_id: "".to_string(),
470 });
471 let blob_storage_config = Some(AzureBlobStorageConfiguration {
472 storage_account: "".to_string(),
473 access_key: SecretString::new(String::new().into()),
474 });
475
476 Ok(Some(AzureConfiguration {
477 chatbot_config,
478 search_config,
479 blob_storage_config,
480 }))
481 }
482}
483
484#[derive(Clone)]
485pub struct OAuthServerConfiguration {
486 pub rsa_public_key: String,
487 pub rsa_private_key: SecretString,
490 pub oauth_token_hmac_key: SecretString,
492 pub dpop_nonce_key: Arc<SecretBox<String>>,
494}
495
496impl PartialEq for OAuthServerConfiguration {
497 fn eq(&self, other: &Self) -> bool {
498 self.rsa_public_key == other.rsa_public_key
499 && self.rsa_private_key.expose_secret() == other.rsa_private_key.expose_secret()
500 && self.oauth_token_hmac_key.expose_secret()
501 == other.oauth_token_hmac_key.expose_secret()
502 && self.dpop_nonce_key.expose_secret() == other.dpop_nonce_key.expose_secret()
503 }
504}
505
506impl OAuthServerConfiguration {
507 pub fn try_from_env() -> anyhow::Result<Self> {
511 let rsa_public_key =
512 env::var("OAUTH_RSA_PUBLIC_PEM").context("OAUTH_RSA_PUBLIC_KEY must be defined")?;
513 let rsa_private_key = SecretString::new(
514 env::var("OAUTH_RSA_PRIVATE_PEM")
515 .context("OAUTH_RSA_PRIVATE_KEY must be defined")?
516 .into(),
517 );
518 let oauth_token_hmac_key = SecretString::new(
519 env::var("OAUTH_TOKEN_HMAC_KEY")
520 .context("OAUTH_TOKEN_HMAC_KEY must be defined")?
521 .into(),
522 );
523 let dpop_nonce_key = Arc::new(SecretBox::new(Box::new(
524 env::var("OAUTH_DPOP_NONCE_KEY").context("OAUTH_DPOP_NONCE_KEY must be defined")?,
525 )));
526
527 Ok(Self {
528 rsa_public_key,
529 rsa_private_key,
530 oauth_token_hmac_key,
531 dpop_nonce_key,
532 })
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn suotar_configuration_has_no_mock_fallback() {
542 assert!(SuotarConfiguration::from_values(None, None, false, 365).is_err());
543 assert!(
544 SuotarConfiguration::from_values(
545 Some("https://suotar.example.com/api".to_string()),
546 None,
547 false,
548 365
549 )
550 .is_err()
551 );
552 assert!(
553 SuotarConfiguration::from_values(None, Some("token".to_string()), false, 365).is_err()
554 );
555 assert!(
556 SuotarConfiguration::from_values(
557 Some("https://suotar.example.com/api".to_string()),
558 Some("token".to_string()),
559 false,
560 365
561 )
562 .is_ok()
563 );
564 }
565
566 #[test]
569 fn suotar_configuration_normalises_the_join_base() {
570 let conf = SuotarConfiguration::from_values(
571 Some("https://suotar.example.com/api".to_string()),
572 Some("token".to_string()),
573 false,
574 365,
575 )
576 .expect("valid fixture values");
577 assert_eq!(
578 conf.api_base_url.as_str(),
579 "https://suotar.example.com/api/"
580 );
581 assert_eq!(
582 conf.api_base_url
583 .join("persons/resolve-by-student-numbers")
584 .expect("a relative join on a base ending in a slash")
585 .as_str(),
586 "https://suotar.example.com/api/persons/resolve-by-student-numbers"
587 );
588 }
589
590 fn azure_chatbot_conf(api_endpoint: &str) -> AzureChatbotConfiguration {
591 AzureChatbotConfiguration::from_values(
592 "key".to_string(),
593 api_endpoint,
594 "some-project".to_string(),
595 )
596 .expect("valid fixture values")
597 }
598
599 fn azure_chatbot_embeddings_endpoint(api_endpoint: &str) -> String {
600 azure_chatbot_conf(api_endpoint)
601 .embeddings_endpoint()
602 .expect("a relative join on a base ending in a slash")
603 .to_string()
604 }
605
606 #[test]
607 fn azure_chatbot_configuration_normalises_the_join_base() {
608 let conf = azure_chatbot_conf("https://example.services.ai.azure.com/foundry");
609 assert_eq!(
610 conf.api_base.as_str(),
611 "https://example.services.ai.azure.com/foundry/"
612 );
613 assert_eq!(
614 conf.responses_endpoint()
615 .expect("a relative join on a base ending in a slash")
616 .as_str(),
617 "https://example.services.ai.azure.com/foundry/api/projects/some-project/openai/v1/responses"
618 );
619 assert_eq!(
620 conf.embeddings_endpoint()
621 .expect("a relative join on a base ending in a slash")
622 .as_str(),
623 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
624 );
625 }
626
627 #[test]
630 fn a_query_a_fragment_or_stray_whitespace_does_not_move_the_join_base() {
631 assert_eq!(
632 azure_chatbot_embeddings_endpoint(
633 "https://example.services.ai.azure.com/foundry?api-version=2024-10-21"
634 ),
635 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
636 );
637 assert_eq!(
638 azure_chatbot_embeddings_endpoint(
639 "https://example.services.ai.azure.com/foundry#anchor"
640 ),
641 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
642 );
643 assert_eq!(
644 azure_chatbot_embeddings_endpoint("https://example.services.ai.azure.com/foundry "),
645 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
646 );
647 assert_eq!(
648 azure_chatbot_embeddings_endpoint("https://example.services.ai.azure.com/foundry/ "),
649 "https://example.services.ai.azure.com/foundry/openai/v1/embeddings"
650 );
651 }
652
653 #[test]
654 fn a_join_base_that_already_ends_in_a_slash_is_left_as_it_is() {
655 assert_eq!(
656 azure_chatbot_conf("https://example.services.ai.azure.com/foundry/")
657 .api_base
658 .as_str(),
659 "https://example.services.ai.azure.com/foundry/"
660 );
661 assert_eq!(
662 azure_chatbot_conf("https://example.services.ai.azure.com/foundry//")
663 .api_base
664 .as_str(),
665 "https://example.services.ai.azure.com/foundry//"
666 );
667 assert_eq!(
668 azure_chatbot_conf("https://example.services.ai.azure.com")
669 .api_base
670 .as_str(),
671 "https://example.services.ai.azure.com/"
672 );
673 }
674
675 #[test]
676 fn mock_conf_points_at_our_own_mock_controller() {
677 let conf = SuotarConfiguration::mock_conf("http://project-331.local")
678 .expect("valid fixture values");
679 assert_eq!(
680 conf.api_base_url.as_str(),
681 "http://project-331.local/api/v0/mock-suotar/"
682 );
683 assert_eq!(conf.api_token.expose_secret(), MOCK_SUOTAR_TOKEN);
684 }
685
686 #[test]
687 fn fast_track_defaults_are_off_and_a_year() {
688 let conf = SuotarConfiguration::mock_conf("http://project-331.local")
689 .expect("valid fixture values");
690 assert!(!conf.fast_track_email_match_enabled);
691 assert_eq!(conf.fast_track_max_email_verification_age_days, 365);
692 }
693}