diff --git a/services/reports/src/service.rs b/services/reports/src/service.rs index 08be48559..94db82e8f 100644 --- a/services/reports/src/service.rs +++ b/services/reports/src/service.rs @@ -1,342 +1,343 @@ use actix_web::FromRequest; use chrono::Utc; use comm_lib::{ auth::{AuthService, AuthorizationCredential}, blob::client::{BlobServiceClient, BlobServiceError}, crypto::aes256, database::{self, blob::BlobOrDBContent}, }; use derive_more::{Display, Error, From}; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use tracing::{error, trace, warn}; use crate::{ config::CONFIG, database::{ client::{DatabaseClient, ReportsPage}, item::ReportItem, }, email::{config::EmailConfig, ReportEmail}, report_types::{ReportID, ReportInput, ReportOutput, ReportType}, }; #[derive(Debug, Display, Error, From)] pub enum ReportsServiceError { DatabaseError(database::Error), BlobError(BlobServiceError), /// Error during parsing user input /// Usually this indicates user error #[from(ignore)] ParseError(serde_json::Error), /// Error during serializing/deserializing internal data /// This is usually a service bug / data inconsistency #[from(ignore)] SerdeError(serde_json::Error), /// Unsupported report type /// Returned when trying to perform an operation on an incompatible report type /// e.g. create a Redux Devtools import from a media mission report UnsupportedReportType, /// Error during encryption or decryption #[display(fmt = "Encryption error")] EncryptionError, /// Unexpected error Unexpected, } type ServiceResult = Result; #[derive(Clone)] pub struct ReportsService { db: DatabaseClient, blob_client: BlobServiceClient, requesting_user_id: Option, email_config: Option>, } impl ReportsService { pub fn new( db: DatabaseClient, blob_client: BlobServiceClient, email_config: Option, ) -> Self { Self { db, blob_client, requesting_user_id: None, email_config: email_config.map(Arc::new), } } /// Clones the service with a new auth identity. When the credential is /// a service-to-service, the `user_id` is None. pub fn with_authentication(&self, token: AuthorizationCredential) -> Self { let requesting_user_id = match &token { AuthorizationCredential::ServicesToken(_) => None, AuthorizationCredential::UserToken(user) => { Some(user.user_id.to_string()) } }; Self { db: self.db.clone(), email_config: self.email_config.clone(), blob_client: self.blob_client.with_authentication(token), requesting_user_id, } } pub async fn save_reports( &self, inputs: Vec, ) -> ServiceResult> { let mut reports = Vec::with_capacity(inputs.len()); let mut tasks = tokio::task::JoinSet::new(); // 1. Concurrently prepare reports. Upload them to blob service if needed for input in inputs { let blob_client = self.blob_client.clone(); let user_id = self.requesting_user_id.clone(); tasks.spawn(async move { let mut report = process_report(input, user_id)?; report.db_item.ensure_size_constraints(&blob_client).await?; Ok(report) }); } // 2. Wait for all uploads to complete and collect results // If any of them failed, abort while let Some(task) = tasks.join_next().await { let result: Result<_, ReportsServiceError> = task.map_err(|err| { error!("Task failed to join: {err}"); ReportsServiceError::Unexpected })?; reports.push(result?); } let (ids, (db_items, emails)): (Vec<_>, (Vec<_>, Vec<_>)) = reports .into_iter() .map(|ProcessedReport { id, db_item, email }| (id, (db_item, email))) .unzip(); // 3. Store the reports in database self.db.save_reports(db_items).await?; // 4. Send e-mails asynchronously tokio::spawn(async move { if let Err(err) = crate::email::send_emails(emails).await { error!("Failed to send e-mails: {err}"); } }); Ok(ids) } pub async fn get_report( &self, report_id: ReportID, ) -> ServiceResult> { use ReportsServiceError::{EncryptionError, SerdeError}; let Some(report_item) = self.db.get_report(&report_id).await? else { return Ok(None); }; let ReportItem { user_id, report_type, platform, creation_time, content, encryption_key, .. } = report_item; let mut report_data = content.fetch_bytes(&self.blob_client).await?; if let Some(key) = encryption_key { trace!("Encryption key present. Decrypting report data"); report_data = aes256::decrypt(&report_data, &key).map_err(|_| { error!("Failed to decrypt report"); EncryptionError })?; } let report_json = serde_json::from_slice(report_data.as_slice()).map_err(SerdeError)?; let output = ReportOutput { id: report_id, user_id, platform, report_type, creation_time, content: report_json, }; Ok(Some(output)) } pub async fn get_redux_devtools_import( &self, report_id: ReportID, ) -> ServiceResult> { let Some(report) = self.get_report(report_id).await? else { return Ok(None); }; if !matches!(report.report_type, ReportType::ErrorReport) { return Err(ReportsServiceError::UnsupportedReportType); }; let redux_devtools_payload = prepare_redux_devtools_import(report.content) .map_err(ReportsServiceError::SerdeError)?; Ok(Some(redux_devtools_payload)) } pub async fn list_reports( &self, cursor: Option, page_size: Option, ) -> ServiceResult { let page = self.db.scan_reports(cursor, page_size).await?; Ok(page) } } impl FromRequest for ReportsService { type Error = actix_web::Error; type Future = Pin>>>; #[inline] fn from_request( req: &actix_web::HttpRequest, - _payload: &mut actix_web::dev::Payload, + payload: &mut actix_web::dev::Payload, ) -> Self::Future { use actix_web::error::{ErrorForbidden, ErrorInternalServerError}; - use actix_web::HttpMessage; let base_service = req.app_data::().cloned().ok_or_else(|| { tracing::error!( "FATAL! Failed to extract ReportsService from actix app_data. \ Check HTTP server configuration" ); ErrorInternalServerError("Internal server error") }); let auth_service = req.app_data::().cloned().ok_or_else(|| { tracing::error!( "FATAL! Failed to extract AuthService from actix app_data. \ Check HTTP server configuration" ); ErrorInternalServerError("Internal server error") }); let request_auth_value = - req.extensions().get::().cloned(); + AuthorizationCredential::from_request(req, payload); Box::pin(async move { let auth_service = auth_service?; let base_service = base_service?; - // This is Some for endpoints hidden behind auth validation middleware - let auth_token = match request_auth_value { + let credential = request_auth_value.await.ok(); + + // This is Some if the request contains valid Authorization header + let auth_token = match credential { Some(token @ AuthorizationCredential::UserToken(_)) => token, Some(_) => { // Reports service shouldn't be called by other services warn!("Reports service requires user authorization"); return Err(ErrorForbidden("Forbidden")); } None => { // Unauthenticated requests get a service-to-service token let services_token = auth_service.get_services_token().await.map_err(|err| { error!("Failed to get services token: {err}"); ErrorInternalServerError("Internal server error") })?; AuthorizationCredential::ServicesToken(services_token) } }; let service = base_service.with_authentication(auth_token); Ok(service) }) } } struct ProcessedReport { id: ReportID, db_item: ReportItem, email: ReportEmail, } fn process_report( input: ReportInput, user_id: Option, ) -> Result { use ReportsServiceError::*; let id = ReportID::default(); let email = crate::email::prepare_email(&input, &id, user_id.as_deref()); let ReportInput { platform_details, report_type, time, mut report_content, } = input; // Add "platformDetails" back to report content. // It was deserialized into a separate field. let platform_details_value = serde_json::to_value(&platform_details).map_err(SerdeError)?; report_content.insert("platformDetails".to_string(), platform_details_value); // serialize report JSON to bytes let content_bytes = serde_json::to_vec(&report_content).map_err(SerdeError)?; // possibly encrypt report let (content, encryption_key) = if CONFIG.encrypt_reports { trace!(?id, "Encrypting report"); let key = aes256::EncryptionKey::new(); let data = aes256::encrypt(&content_bytes, &key).map_err(|_| { error!("Failed to encrypt report"); EncryptionError })?; (data, Some(key)) } else { (content_bytes, None) }; let db_item = ReportItem { id: id.clone(), user_id: user_id.unwrap_or("[null]".to_string()), platform: platform_details.platform.clone(), report_type, creation_time: time.unwrap_or_else(Utc::now), encryption_key, content: BlobOrDBContent::new(content), }; Ok(ProcessedReport { id, db_item, email }) } /// Transforms report content JSON into format that can be /// imported into Redux DevTools. fn prepare_redux_devtools_import( mut error_report: HashMap, ) -> Result { use serde_json::{json, map::Map, Value}; let nav_state = error_report.remove("navState"); let actions = error_report.remove("actions"); let mut preloaded_state = error_report .remove("preloadedState") .unwrap_or_else(|| Value::Object(Map::new())); preloaded_state["navState"] = nav_state.into(); preloaded_state["frozen"] = true.into(); preloaded_state["_persist"]["rehydrated"] = false.into(); let preload_state_str = serde_json::to_string(&preloaded_state)?; let payload_str = serde_json::to_string(&actions)?; Ok(json!({ "preloadedState": preload_state_str, "payload": payload_str, })) } diff --git a/shared/comm-lib/src/auth/service.rs b/shared/comm-lib/src/auth/service.rs index 5e4d5e24b..18127e370 100644 --- a/shared/comm-lib/src/auth/service.rs +++ b/shared/comm-lib/src/auth/service.rs @@ -1,165 +1,165 @@ use aws_sdk_secretsmanager::Client as SecretsManagerClient; use chrono::{DateTime, Duration, NaiveDateTime, Utc}; use grpc_clients::identity::unauthenticated::client as identity_client; use super::{AuthorizationCredential, ServicesAuthToken, UserIdentity}; const SECRET_NAME: &str = "servicesToken"; /// duration for which we consider previous token valid /// after rotation const ROTATION_PROTECTION_PERIOD: i64 = 3; // seconds // AWS managed version tags for secrets const AWSCURRENT: &str = "AWSCURRENT"; const AWSPREVIOUS: &str = "AWSPREVIOUS"; // Identity service gRPC clients require a code version and device type. // We can supply some placeholder values for services for the time being, since // this metadata is only relevant for devices. const PLACEHOLDER_CODE_VERSION: u64 = 0; const DEVICE_TYPE: &str = "service"; #[derive( Debug, derive_more::Display, derive_more::Error, derive_more::From, )] pub enum AuthServiceError { SecretManagerError(aws_sdk_secretsmanager::Error), GrpcError(grpc_clients::error::Error), Unexpected, } type AuthServiceResult = Result; /// This service is responsible for handling request authentication. /// For HTTP services, it should be added as app data to the server: /// ```ignore /// let auth_service = AuthService::new(&aws_config, &config.identity_endpoint); /// let auth_middleware = get_comm_authentication_middleware(); /// App::new() /// .app_data(auth_service.clone()) /// .wrap(auth_middleware) /// // ... /// ``` #[derive(Clone)] pub struct AuthService { secrets_manager: SecretsManagerClient, identity_service_url: String, } impl AuthService { pub fn new( aws_cfg: &aws_config::SdkConfig, identity_service_url: impl Into, ) -> Self { let secrets_client = SecretsManagerClient::new(aws_cfg); AuthService { secrets_manager: secrets_client, identity_service_url: identity_service_url.into(), } } /// Obtains a service-to-service token which can be used to authenticate /// when calling other services endpoints. It should be only used when /// no [`UserIdentity`] is provided from client pub async fn get_services_token( &self, ) -> AuthServiceResult { get_services_token_version(&self.secrets_manager, AWSCURRENT) .await .map_err(AuthServiceError::from) } /// Verifies the provided [`AuthorizationCredential`]. Returns `true` if /// authentication was successful. pub async fn verify_auth_credential( &self, credential: &AuthorizationCredential, ) -> AuthServiceResult { match credential { AuthorizationCredential::UserToken(user) => { let UserIdentity { user_id, device_id, access_token, } = user; identity_client::verify_user_access_token( &self.identity_service_url, user_id, device_id, access_token, PLACEHOLDER_CODE_VERSION, DEVICE_TYPE.to_string(), ) .await .map_err(AuthServiceError::from) } AuthorizationCredential::ServicesToken(token) => { verify_services_token(&self.secrets_manager, token) .await .map_err(AuthServiceError::from) } } } } async fn get_services_token_version( client: &SecretsManagerClient, version: impl Into, ) -> Result { let result = client .get_secret_value() .secret_id(SECRET_NAME) - .version_id(version) + .version_stage(version) .send() .await?; let token = result .secret_string() .expect("Services token secret is not a string. This should not happen"); Ok(ServicesAuthToken::new(token.to_string())) } async fn time_since_rotation( client: &SecretsManagerClient, ) -> Result, aws_sdk_secretsmanager::Error> { let result = client .describe_secret() .secret_id(SECRET_NAME) .send() .await?; let duration = result .last_rotated_date() .and_then(|date| date.to_millis().ok()) .and_then(NaiveDateTime::from_timestamp_millis) .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) .map(|last_rotated| Utc::now().signed_duration_since(last_rotated)); Ok(duration) } async fn verify_services_token( client: &SecretsManagerClient, token_to_verify: &ServicesAuthToken, ) -> Result { let actual_token = get_services_token_version(client, AWSCURRENT).await?; // we need to always get it to achieve constant time eq let last_rotated = time_since_rotation(client).await?; let was_recently_rotated = last_rotated .filter(|rotation_time| { *rotation_time < Duration::seconds(ROTATION_PROTECTION_PERIOD) }) .is_some(); let is_valid = *token_to_verify == actual_token; // token might have just been rotated. In this case check the previous token // this case makes the function non-constant time, but it happens very rarely if !is_valid && was_recently_rotated { let previous_token = get_services_token_version(client, AWSPREVIOUS).await?; let previous_valid = *token_to_verify == previous_token; return Ok(previous_valid); } Ok(is_valid) }