diff --git a/.sqlx/query-541804a2e0cf4aacb664c00738e83871ebb8cf4523a8ed9f136ce4a77469bfd6.json b/.sqlx/query-541804a2e0cf4aacb664c00738e83871ebb8cf4523a8ed9f136ce4a77469bfd6.json new file mode 100644 index 0000000..9521493 --- /dev/null +++ b/.sqlx/query-541804a2e0cf4aacb664c00738e83871ebb8cf4523a8ed9f136ce4a77469bfd6.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\nSELECT id as \"id: uuid::fmt::Hyphenated\", user_id, resource, created_at\nFROM user_shares\nWHERE user_id = ?1 AND resource = ?2\n ", + "describe": { + "columns": [ + { + "name": "id: uuid::fmt::Hyphenated", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int64" + }, + { + "name": "resource", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "541804a2e0cf4aacb664c00738e83871ebb8cf4523a8ed9f136ce4a77469bfd6" +} diff --git a/.sqlx/query-bf66332e2ff3a8f98a2603cf8e28a4afe82accdb06626864268d67b6f8174319.json b/.sqlx/query-bf66332e2ff3a8f98a2603cf8e28a4afe82accdb06626864268d67b6f8174319.json new file mode 100644 index 0000000..25c9414 --- /dev/null +++ b/.sqlx/query-bf66332e2ff3a8f98a2603cf8e28a4afe82accdb06626864268d67b6f8174319.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\nSELECT id as \"id: uuid::fmt::Hyphenated\", user_id, resource, created_at\nFROM user_shares\nWHERE id = ?1\n ", + "describe": { + "columns": [ + { + "name": "id: uuid::fmt::Hyphenated", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int64" + }, + { + "name": "resource", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "bf66332e2ff3a8f98a2603cf8e28a4afe82accdb06626864268d67b6f8174319" +} diff --git a/.sqlx/query-f439499d9ce154a3cb6912bb907515fc6c38e687f94b79c7c8eb21b1fec42c23.json b/.sqlx/query-f439499d9ce154a3cb6912bb907515fc6c38e687f94b79c7c8eb21b1fec42c23.json new file mode 100644 index 0000000..cf0946c --- /dev/null +++ b/.sqlx/query-f439499d9ce154a3cb6912bb907515fc6c38e687f94b79c7c8eb21b1fec42c23.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\nINSERT INTO user_shares (id, user_id, resource, created_at)\nVALUES (?1, ?2, ?3, ?4)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "f439499d9ce154a3cb6912bb907515fc6c38e687f94b79c7c8eb21b1fec42c23" +} diff --git a/.sqlx/query-f508c94cef60a0c2b707814ccfedf9c4bdfca11dedce461f780fb7b1dd457776.json b/.sqlx/query-f508c94cef60a0c2b707814ccfedf9c4bdfca11dedce461f780fb7b1dd457776.json new file mode 100644 index 0000000..0502e80 --- /dev/null +++ b/.sqlx/query-f508c94cef60a0c2b707814ccfedf9c4bdfca11dedce461f780fb7b1dd457776.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\nDELETE FROM user_shares\nWHERE id = ?1\nRETURNING id as \"id: uuid::fmt::Hyphenated\", user_id as \"user_id!\", resource as \"resource!\", created_at as \"created_at!\"\n ", + "describe": { + "columns": [ + { + "name": "id: uuid::fmt::Hyphenated", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id!", + "ordinal": 1, + "type_info": "Int64" + }, + { + "name": "resource!", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "created_at!", + "ordinal": 3, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f508c94cef60a0c2b707814ccfedf9c4bdfca11dedce461f780fb7b1dd457776" +} diff --git a/migrations/20230927212931_user_shares.sql b/migrations/20230927212931_user_shares.sql new file mode 100644 index 0000000..bf5db94 --- /dev/null +++ b/migrations/20230927212931_user_shares.sql @@ -0,0 +1,8 @@ +-- Table to store user public shares (content security policies, certificate templates etc.). +CREATE TABLE IF NOT EXISTS user_shares +( + id TEXT PRIMARY KEY NOT NULL COLLATE NOCASE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource BLOB NOT NULL, + created_at INTEGER NOT NULL +) STRICT; diff --git a/src/main.rs b/src/main.rs index 82413e8..532a871 100644 --- a/src/main.rs +++ b/src/main.rs @@ -240,6 +240,7 @@ mod tests { users::{User, UserId}, utils::{WebPageResource, WebPageResourceContent, WebPageResourceContentData}, }; + use anyhow::anyhow; use cron::Schedule; use lettre::transport::stub::AsyncStubTransport; use std::{ @@ -252,7 +253,7 @@ mod tests { use trust_dns_resolver::proto::rr::Record; use url::Url; - pub use crate::{network::tests::*, utils::tests::*}; + pub use crate::{network::tests::*, server::tests::*, utils::tests::*}; use crate::{search::SearchIndex, templates::create_templates}; pub struct MockUserBuilder { @@ -386,11 +387,20 @@ mod tests { Database::open(|| Ok("sqlite::memory:".to_string())).await } + pub fn mock_search_index() -> anyhow::Result { + SearchIndex::open(|schema| Ok(Index::create_in_ram(schema))) + } + pub fn mock_user() -> anyhow::Result { + mock_user_with_id(1) + } + + pub fn mock_user_with_id>(id: I) -> anyhow::Result { + let id = id.try_into().map_err(|_| anyhow!("err"))?; Ok(MockUserBuilder::new( - 1.try_into()?, - "dev@secutils.dev", - "dev-handle", + id, + &format!("dev-{}@secutils.dev", *id), + &format!("dev-handle-{}", *id), StoredCredentials { password_hash: Some("hash".to_string()), ..Default::default() @@ -446,7 +456,7 @@ mod tests { Ok(Api::new( config, mock_db().await?, - SearchIndex::open(|schema| Ok(Index::create_in_ram(schema)))?, + mock_search_index()?, mock_network(), create_templates()?, )) @@ -458,7 +468,7 @@ mod tests { Ok(Api::new( mock_config()?, mock_db().await?, - SearchIndex::open(|schema| Ok(Index::create_in_ram(schema)))?, + mock_search_index()?, network, create_templates()?, )) diff --git a/src/notifications/api_ext.rs b/src/notifications/api_ext.rs index e4743db..e0929ea 100644 --- a/src/notifications/api_ext.rs +++ b/src/notifications/api_ext.rs @@ -341,8 +341,8 @@ mod tests { Envelope { forward_path: [ Address { - serialized: "dev@secutils.dev", - at_start: 3, + serialized: "dev-1@secutils.dev", + at_start: 5, }, ], reverse_path: Some( @@ -352,7 +352,7 @@ mod tests { }, ), }, - "From: dev@secutils.dev\r\nReply-To: dev@secutils.dev\r\nTo: dev@secutils.dev\r\nSubject: [NO SUBJECT]\r\nDate: Sat, 01 Jan 2000 09:58:20 +0000\r\nContent-Transfer-Encoding: 7bit\r\n\r\nabc", + "From: dev@secutils.dev\r\nReply-To: dev@secutils.dev\r\nTo: dev-1@secutils.dev\r\nSubject: [NO SUBJECT]\r\nDate: Sat, 01 Jan 2000 09:58:20 +0000\r\nContent-Transfer-Encoding: 7bit\r\n\r\nabc", ), ( Envelope { diff --git a/src/notifications/notification_content.rs b/src/notifications/notification_content.rs index a46b735..38232d5 100644 --- a/src/notifications/notification_content.rs +++ b/src/notifications/notification_content.rs @@ -172,9 +172,9 @@ mod tests { .await?, @r###" EmailNotificationContent { subject: "Activate you Secutils.dev account", - text: "To activate your Secutils.dev account, please click the following link: http://localhost:1234/activate?code=some-code&email=dev%40secutils.dev", + text: "To activate your Secutils.dev account, please click the following link: http://localhost:1234/activate?code=some-code&email=dev-1%40secutils.dev", html: Some( - "\n\n\n Activate your Secutils.dev account\n \n \n \n\n\n
\n

Activate your Secutils.dev account

\n

Thanks for signing up! To activate your account, please click the link below:

\n Activate my account\n

If the button above doesn't work, you can also copy and paste the following URL into your browser:

\n

http://localhost:1234/activate?code=some-code&email=dev%40secutils.dev

\n

If you have any trouble activating your account, please contact us at contact@secutils.dev.

\n
\n\n\n", + "\n\n\n Activate your Secutils.dev account\n \n \n \n\n\n
\n

Activate your Secutils.dev account

\n

Thanks for signing up! To activate your account, please click the link below:

\n Activate my account\n

If the button above doesn't work, you can also copy and paste the following URL into your browser:

\n

http://localhost:1234/activate?code=some-code&email=dev-1%40secutils.dev

\n

If you have any trouble activating your account, please contact us at contact@secutils.dev.

\n
\n\n\n", ), attachments: None, } diff --git a/src/notifications/notification_content_template.rs b/src/notifications/notification_content_template.rs index 4a57013..2792549 100644 --- a/src/notifications/notification_content_template.rs +++ b/src/notifications/notification_content_template.rs @@ -80,9 +80,9 @@ mod tests { .await?, @r###" EmailNotificationContent { subject: "Activate you Secutils.dev account", - text: "To activate your Secutils.dev account, please click the following link: http://localhost:1234/activate?code=some-code&email=dev%40secutils.dev", + text: "To activate your Secutils.dev account, please click the following link: http://localhost:1234/activate?code=some-code&email=dev-1%40secutils.dev", html: Some( - "\n\n\n Activate your Secutils.dev account\n \n \n \n\n\n
\n

Activate your Secutils.dev account

\n

Thanks for signing up! To activate your account, please click the link below:

\n Activate my account\n

If the button above doesn't work, you can also copy and paste the following URL into your browser:

\n

http://localhost:1234/activate?code=some-code&email=dev%40secutils.dev

\n

If you have any trouble activating your account, please contact us at contact@secutils.dev.

\n
\n\n\n", + "\n\n\n Activate your Secutils.dev account\n \n \n \n\n\n
\n

Activate your Secutils.dev account

\n

Thanks for signing up! To activate your account, please click the link below:

\n Activate my account\n

If the button above doesn't work, you can also copy and paste the following URL into your browser:

\n

http://localhost:1234/activate?code=some-code&email=dev-1%40secutils.dev

\n

If you have any trouble activating your account, please contact us at contact@secutils.dev.

\n
\n\n\n", ), attachments: None, } @@ -115,9 +115,9 @@ mod tests { .await?, @r###" EmailNotificationContent { subject: "Reset password for your Secutils.dev account", - text: "To reset your Secutils.dev password, please click the following link: http://localhost:1234/reset_credentials?code=some-code&email=dev%40secutils.dev", + text: "To reset your Secutils.dev password, please click the following link: http://localhost:1234/reset_credentials?code=some-code&email=dev-1%40secutils.dev", html: Some( - "\n\n\n Reset password for your Secutils.dev account\n \n \n \n\n\n
\n

Reset password for your Secutils.dev account

\n

You recently requested to reset your password. To reset your password, please click the link below:

\n Reset your password\n

If the button above doesn't work, you can also copy and paste the following URL into your browser:

\n

http://localhost:1234/reset_credentials?code=some-code&email=dev%40secutils.dev

\n

If you did not request to reset your password, please ignore this email and your password will not be changed.

\n

If you have any trouble resetting your password, please contact us at contact@secutils.dev.

\n
\n\n\n", + "\n\n\n Reset password for your Secutils.dev account\n \n \n \n\n\n
\n

Reset password for your Secutils.dev account

\n

You recently requested to reset your password. To reset your password, please click the link below:

\n Reset your password\n

If the button above doesn't work, you can also copy and paste the following URL into your browser:

\n

http://localhost:1234/reset_credentials?code=some-code&email=dev-1%40secutils.dev

\n

If you did not request to reset your password, please ignore this email and your password will not be changed.

\n

If you have any trouble resetting your password, please contact us at contact@secutils.dev.

\n
\n\n\n", ), attachments: None, } diff --git a/src/scheduler/scheduler_jobs/notifications_send_job.rs b/src/scheduler/scheduler_jobs/notifications_send_job.rs index ca429e2..603fd68 100644 --- a/src/scheduler/scheduler_jobs/notifications_send_job.rs +++ b/src/scheduler/scheduler_jobs/notifications_send_job.rs @@ -272,8 +272,8 @@ mod tests { Envelope { forward_path: [ Address { - serialized: "dev@secutils.dev", - at_start: 3, + serialized: "dev-1@secutils.dev", + at_start: 5, }, ], reverse_path: Some( @@ -283,7 +283,7 @@ mod tests { }, ), }, - "From: dev@secutils.dev\r\nReply-To: dev@secutils.dev\r\nTo: dev@secutils.dev\r\nSubject: [NO SUBJECT]\r\nDate: Sat, 01 Jan 2000 10:00:00 +0000\r\nContent-Transfer-Encoding: 7bit\r\n\r\nmessage 0", + "From: dev@secutils.dev\r\nReply-To: dev@secutils.dev\r\nTo: dev-1@secutils.dev\r\nSubject: [NO SUBJECT]\r\nDate: Sat, 01 Jan 2000 10:00:00 +0000\r\nContent-Transfer-Encoding: 7bit\r\n\r\nmessage 0", ) "###); diff --git a/src/server.rs b/src/server.rs index 5a1bb8b..36ef3d0 100644 --- a/src/server.rs +++ b/src/server.rs @@ -13,7 +13,6 @@ use crate::{ scheduler::Scheduler, search::{populate_search_index, SearchIndex}, security::{create_webauthn, Security}, - server::app_state::AppState, templates::create_templates, users::builtin_users_initializer, }; @@ -24,6 +23,11 @@ use anyhow::Context; use lettre::{transport::smtp::authentication::Credentials, AsyncSmtpTransport, Tokio1Executor}; use std::sync::Arc; +#[cfg(test)] +pub use self::app_state::tests; + +pub use app_state::AppState; + #[actix_rt::main] pub async fn run( config: Config, diff --git a/src/server/app_state.rs b/src/server/app_state.rs index 0d51d43..13eabf0 100644 --- a/src/server/app_state.rs +++ b/src/server/app_state.rs @@ -43,3 +43,38 @@ impl AppState { Ok(()) } } + +#[cfg(test)] +pub mod tests { + use crate::{ + api::Api, + network::{Network, TokioDnsResolver}, + security::{create_webauthn, Security}, + server::AppState, + templates::create_templates, + tests::{mock_config, mock_db, mock_search_index}, + }; + use lettre::{AsyncSmtpTransport, Tokio1Executor}; + use std::sync::Arc; + + pub async fn mock_app_state() -> anyhow::Result { + let api = Arc::new(Api::new( + mock_config()?, + mock_db().await?, + mock_search_index()?, + // We should use a real network implementation in tests that rely on `AppState` being + // extracted from `HttpRequest`, as types should match for the extraction to work. + Network::new( + TokioDnsResolver::create(), + AsyncSmtpTransport::::unencrypted_localhost(), + ), + create_templates()?, + )); + + Ok(AppState::new( + api.config.clone(), + Security::new(api.clone(), create_webauthn(&api.config)?), + api, + )) + } +} diff --git a/src/server/extractors.rs b/src/server/extractors.rs index 0eba110..2994893 100644 --- a/src/server/extractors.rs +++ b/src/server/extractors.rs @@ -1 +1,2 @@ mod user; +mod user_share; diff --git a/src/server/extractors/user_share.rs b/src/server/extractors/user_share.rs new file mode 100644 index 0000000..e046517 --- /dev/null +++ b/src/server/extractors/user_share.rs @@ -0,0 +1,163 @@ +use crate::{ + server::AppState, + users::{UserShare, UserShareId}, +}; +use actix_http::{header::HeaderName, Payload}; +use actix_web::{ + error::{ErrorBadRequest, ErrorInternalServerError, ErrorUnauthorized}, + web, Error, FromRequest, HttpRequest, +}; +use anyhow::anyhow; +use std::{future::Future, pin::Pin}; + +pub static USER_SHARE_ID_HEADER_NAME: HeaderName = HeaderName::from_static("x-user-share-id"); + +/// Extractor used to extract `UserShare` reference from the request via `X-Share-ID` HTTP header. +impl FromRequest for UserShare { + type Error = Error; + type Future = Pin>>>; + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let req = req.clone(); + Box::pin(async move { + // 1. Try to extract `X-Share-ID` header value. + let header_value = if let Some(header) = req.headers().get(&USER_SHARE_ID_HEADER_NAME) { + header + .to_str() + .map_err(|_| ErrorBadRequest(anyhow!("Invalid X-Share-ID header.")))? + } else { + return Err(ErrorUnauthorized(anyhow!("X-Share-ID header is missing."))); + }; + + // 2. Make sure that the header value is a valid `UserShareId` (UUIDv4). + let user_share_id: UserShareId = header_value.parse().map_err(|err| { + log::error!("Invalid X-Share-ID header `{}`: {:?}", header_value, err); + ErrorBadRequest(anyhow!("Invalid X-Share-ID header.")) + })?; + + // 3. Retrieve `UserShare` from the database using the extracted `UserShareId`. + let state = web::Data::::extract(&req).await?; + let users = state.api.users(); + let user_share = users.get_user_share(user_share_id).await.map_err(|err| { + log::error!( + "Cannot retrieve user share ({}) due to unexpected error: {:?}.", + *user_share_id, + err + ); + ErrorInternalServerError(anyhow!("Internal server error")) + })?; + + // 4. Make sure that the `UserShare` is still available, otherwise fail with an error. + user_share.ok_or_else(|| { + log::error!( + "Tried to access unavailable user share ({}).", + *user_share_id + ); + ErrorUnauthorized(anyhow!( + "X-Share-ID header points to non-existent user share." + )) + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::USER_SHARE_ID_HEADER_NAME; + use crate::{ + tests::{mock_app_state, mock_user}, + users::{SharedResource, UserShare, UserShareId}, + }; + use actix_http::Payload; + use actix_web::{test::TestRequest, FromRequest}; + use insta::assert_debug_snapshot; + use time::OffsetDateTime; + + #[actix_rt::test] + async fn fails_if_header_is_not_provided() -> anyhow::Result<()> { + let request = TestRequest::default().to_http_request(); + assert_debug_snapshot!(UserShare::from_request(&request, &mut Payload::None).await, @r###" + Err( + X-Share-ID header is missing., + ) + "###); + Ok(()) + } + + #[actix_rt::test] + async fn fails_if_header_is_not_valid() -> anyhow::Result<()> { + let request = TestRequest::default() + .insert_header((USER_SHARE_ID_HEADER_NAME.clone(), "xxx")) + .to_http_request(); + assert_debug_snapshot!(UserShare::from_request(&request, &mut Payload::None).await, @r###" + Err( + Invalid X-Share-ID header., + ) + "###); + Ok(()) + } + + #[actix_rt::test] + async fn fails_if_user_share_is_not_available() -> anyhow::Result<()> { + let user = mock_user()?; + let mock_user_share = UserShare { + id: UserShareId::new(), + user_id: user.id, + resource: SharedResource::ContentSecurityPolicy { + policy_name: "my-policy".to_string(), + }, + created_at: OffsetDateTime::now_utc(), + }; + + let app_state = mock_app_state().await?; + let users = app_state.api.users(); + users.upsert(&user).await?; + + let request = TestRequest::default() + .insert_header(( + USER_SHARE_ID_HEADER_NAME.clone(), + mock_user_share.id.hyphenated().to_string(), + )) + .data(app_state) + .to_http_request(); + assert_debug_snapshot!(UserShare::from_request(&request, &mut Payload::None).await, @r###" + Err( + X-Share-ID header points to non-existent user share., + ) + "###); + Ok(()) + } + + #[actix_rt::test] + async fn can_extract_user_share() -> anyhow::Result<()> { + let user = mock_user()?; + let mock_user_share = UserShare { + id: UserShareId::new(), + user_id: user.id, + resource: SharedResource::ContentSecurityPolicy { + policy_name: "my-policy".to_string(), + }, + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }; + + let app_state = mock_app_state().await?; + let users = app_state.api.users(); + users.upsert(&user).await?; + users.insert_user_share(&mock_user_share).await?; + + let request = TestRequest::default() + .insert_header(( + USER_SHARE_ID_HEADER_NAME.clone(), + mock_user_share.id.hyphenated().to_string(), + )) + .data(app_state) + .to_http_request(); + assert_eq!( + UserShare::from_request(&request, &mut Payload::None) + .await + .unwrap(), + mock_user_share + ); + Ok(()) + } +} diff --git a/src/server/handlers/ui_state_get.rs b/src/server/handlers/ui_state_get.rs index 8952826..09eb7dc 100644 --- a/src/server/handlers/ui_state_get.rs +++ b/src/server/handlers/ui_state_get.rs @@ -1,7 +1,7 @@ use crate::{ error::SecutilsError, - server::{app_state::AppState, status::Status}, - users::{PublicUserDataNamespace, User, UserSettings}, + server::{status::Status, AppState}, + users::{ClientUserShare, PublicUserDataNamespace, User, UserSettings, UserShare}, utils::Util, }; use actix_web::{web, HttpResponse}; @@ -21,6 +21,8 @@ struct UiState<'a> { #[serde(skip_serializing_if = "Option::is_none")] user: Option, #[serde(skip_serializing_if = "Option::is_none")] + user_share: Option, + #[serde(skip_serializing_if = "Option::is_none")] settings: Option, utils: Vec, } @@ -28,20 +30,27 @@ struct UiState<'a> { pub async fn ui_state_get( state: web::Data, user: Option, + user_share: Option, ) -> Result { - let (settings, utils) = if let Some(ref user) = user { - ( - state - .api - .users() - .get_data(user.id, PublicUserDataNamespace::UserSettings) - .await? - .map(|user_data| user_data.value), - state.api.utils().get_all().await?, - ) + // Settings only available for authenticated users. + let settings = if let Some(ref user) = user { + state + .api + .users() + .get_data(user.id, PublicUserDataNamespace::UserSettings) + .await? + .map(|user_data| user_data.value) } else { - (None, vec![]) + None }; + + // Utils are only available for authenticated users or when accessing shared resources. + let utils = if user.is_some() || user_share.is_some() { + state.api.utils().get_all().await? + } else { + vec![] + }; + Ok(HttpResponse::Ok().json(UiState { status: state .status @@ -50,6 +59,7 @@ pub async fn ui_state_get( .deref(), license: License, user, + user_share: user_share.map(ClientUserShare::from), settings, utils, })) diff --git a/src/server/handlers/utils_handle_action.rs b/src/server/handlers/utils_handle_action.rs index 489236b..ad5ef82 100644 --- a/src/server/handlers/utils_handle_action.rs +++ b/src/server/handlers/utils_handle_action.rs @@ -1,4 +1,9 @@ -use crate::{error::SecutilsError, server::app_state::AppState, users::User, utils::UtilsAction}; +use crate::{ + error::SecutilsError, + server::AppState, + users::{User, UserShare}, + utils::UtilsAction, +}; use actix_web::{web, HttpResponse}; use serde::Deserialize; use serde_json::json; @@ -8,23 +13,57 @@ pub struct BodyParams { action: UtilsAction, } +fn unauthorized_response() -> HttpResponse { + HttpResponse::Unauthorized().json(json!({ + "message": "User is not authorized to perform this action" + })) +} + pub async fn utils_handle_action( state: web::Data, - user: User, + user: Option, + user_share: Option, body_params: web::Json, ) -> Result { - let user_id = user.id; - let action = body_params.into_inner().action; + + // Detect on behalf of what user to handle the action. + let user = match (user, user_share) { + // If user is authenticated, and action is not targeting a shared resource, act on behalf of + // the currently authenticated user. + (Some(user), None) => user, + + // If user is authenticated, and action is targeting a shared resource that belongs to the + // user, act on behalf of the currently authenticated user. + (Some(user), Some(user_share)) if user.id == user_share.user_id => user, + + // If action is targeting a shared resource that doesn't belong to currently authenticated + // user or user isn't authenticated, act on behalf of the shared resource owner assuming + // action is authorized to be performed on a shared resource. + (_, Some(user_share)) if user_share.is_action_authorized(&action) => { + // If user isn't found forbid any actions on the shared resource. + if let Some(user) = state.api.users().get(user_share.user_id).await? { + user + } else { + return Ok(unauthorized_response()); + } + } + + // Otherwise return "Unauthorized" error. + _ => return Ok(unauthorized_response()), + }; + + // Validate action parameters. if let Err(err) = action.validate(&state.api).await { log::error!( "User ({}) tried to perform invalid utility action: {}", - *user_id, + *user.id, err ); return Ok(HttpResponse::BadRequest().json(json!({ "message": err.to_string() }))); } + let user_id = user.id; action .handle(user, &state.api) .await diff --git a/src/users.rs b/src/users.rs index 1dc9f0b..bd89cae 100644 --- a/src/users.rs +++ b/src/users.rs @@ -11,6 +11,7 @@ mod user_data_namespace; mod user_id; mod user_role; mod user_settings; +mod user_share; pub use self::{ api_ext::errors::UserSignupError, @@ -25,6 +26,7 @@ pub use self::{ user_id::UserId, user_role::UserRole, user_settings::{UserSettings, UserSettingsSetter}, + user_share::{ClientSharedResource, ClientUserShare, SharedResource, UserShare, UserShareId}, }; pub(crate) use self::api_ext::user_data_setters::DictionaryDataUserDataSetter; diff --git a/src/users/api_ext.rs b/src/users/api_ext.rs index bfbb904..f3d9ec1 100644 --- a/src/users/api_ext.rs +++ b/src/users/api_ext.rs @@ -2,10 +2,11 @@ use crate::{ api::Api, network::{DnsResolver, EmailTransport}, users::{ - BuiltinUser, DictionaryDataUserDataSetter, PublicUserDataNamespace, User, UserData, - UserDataKey, UserDataNamespace, UserId, UserSettingsSetter, + BuiltinUser, DictionaryDataUserDataSetter, PublicUserDataNamespace, SharedResource, User, + UserData, UserDataKey, UserDataNamespace, UserId, UserSettingsSetter, UserShare, + UserShareId, }, - utils::{AutoResponder, ContentSecurityPolicy, SelfSignedCertificate}, + utils::{AutoResponder, SelfSignedCertificate}, }; use anyhow::{bail, Context}; use serde::de::DeserializeOwned; @@ -103,9 +104,6 @@ impl<'a, DR: DnsResolver, ET: EmailTransport> UsersApi<'a, DR, ET> { PublicUserDataNamespace::AutoResponders => { self.set_auto_responders_data(user_data).await } - PublicUserDataNamespace::ContentSecurityPolicies => { - self.set_content_security_policies_data(user_data).await - } PublicUserDataNamespace::SelfSignedCertificates => { self.set_self_signed_certificates_data(user_data).await } @@ -131,6 +129,33 @@ impl<'a, DR: DnsResolver, ET: EmailTransport> UsersApi<'a, DR, ET> { self.api.db.remove_user_data(user_id, user_data_key).await } + /// Retrieves the user share by the specified ID. + pub async fn get_user_share(&self, id: UserShareId) -> anyhow::Result> { + self.api.db.get_user_share(id).await + } + + /// Retrieves the user share by the specified user ID and resource. + pub async fn get_user_share_by_resource( + &self, + user_id: UserId, + resource: &SharedResource, + ) -> anyhow::Result> { + self.api + .db + .get_user_share_by_resource(user_id, resource) + .await + } + + /// Inserts user share into the database. + pub async fn insert_user_share(&self, user_share: &UserShare) -> anyhow::Result<()> { + self.api.db.insert_user_share(user_share).await + } + + /// Removes user share with the specified ID from the database. + pub async fn remove_user_share(&self, id: UserShareId) -> anyhow::Result> { + self.api.db.remove_user_share(id).await + } + async fn set_auto_responders_data( &self, serialized_user_data: UserData>, @@ -198,27 +223,6 @@ impl<'a, DR: DnsResolver, ET: EmailTransport> UsersApi<'a, DR, ET> { .await } - async fn set_content_security_policies_data( - &self, - serialized_user_data: UserData>, - ) -> anyhow::Result<()> { - DictionaryDataUserDataSetter::upsert( - &self.api.db, - PublicUserDataNamespace::ContentSecurityPolicies, - UserData::new( - serialized_user_data.user_id, - serde_json::from_slice::>>( - &serialized_user_data.value, - ) - .with_context(|| { - "Cannot deserialize new content security policies data".to_string() - })?, - serialized_user_data.timestamp, - ), - ) - .await - } - async fn set_self_signed_certificates_data( &self, serialized_user_data: UserData>, diff --git a/src/users/database_ext.rs b/src/users/database_ext.rs index 1f52f20..424d863 100644 --- a/src/users/database_ext.rs +++ b/src/users/database_ext.rs @@ -1,11 +1,18 @@ mod raw_user; mod raw_user_data; +mod raw_user_share; mod raw_user_to_upsert; -use self::{raw_user::RawUser, raw_user_data::RawUserData, raw_user_to_upsert::RawUserToUpsert}; +use self::{ + raw_user::RawUser, raw_user_data::RawUserData, raw_user_share::RawUserShare, + raw_user_to_upsert::RawUserToUpsert, +}; use crate::{ database::Database, - users::{User, UserData, UserDataKey, UserDataNamespace, UserId}, + users::{ + SharedResource, User, UserData, UserDataKey, UserDataNamespace, UserId, UserShare, + UserShareId, + }, }; use anyhow::{bail, Context}; use serde::{de::DeserializeOwned, Serialize}; @@ -278,16 +285,96 @@ WHERE value = ?1 AND namespace = ?2 .map(UserData::try_from) .collect() } + + /// Retrieves user share from `user_shares` table using user share ID. + pub async fn get_user_share(&self, id: UserShareId) -> anyhow::Result> { + let id = id.hyphenated(); + query_as!( + RawUserShare, + r#" +SELECT id as "id: uuid::fmt::Hyphenated", user_id, resource, created_at +FROM user_shares +WHERE id = ?1 + "#, + id + ) + .fetch_optional(&self.pool) + .await? + .map(UserShare::try_from) + .transpose() + } + + /// Retrieves user share from `user_shares` table using user ID and resource. + pub async fn get_user_share_by_resource( + &self, + user_id: UserId, + resource: &SharedResource, + ) -> anyhow::Result> { + let resource = postcard::to_stdvec(resource)?; + query_as!( + RawUserShare, + r#" +SELECT id as "id: uuid::fmt::Hyphenated", user_id, resource, created_at +FROM user_shares +WHERE user_id = ?1 AND resource = ?2 + "#, + *user_id, + resource + ) + .fetch_optional(&self.pool) + .await? + .map(UserShare::try_from) + .transpose() + } + + /// Inserts user share to the `user_shares` table. + pub async fn insert_user_share(&self, user_share: &UserShare) -> anyhow::Result<()> { + let raw_user_share = RawUserShare::try_from(user_share)?; + + query!( + r#" +INSERT INTO user_shares (id, user_id, resource, created_at) +VALUES (?1, ?2, ?3, ?4) + "#, + raw_user_share.id, + raw_user_share.user_id, + raw_user_share.resource, + raw_user_share.created_at + ) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Removes user share from the `user_shares` table using user share ID and returns removed + /// user share object if it was found. + pub async fn remove_user_share(&self, id: UserShareId) -> anyhow::Result> { + let id = id.hyphenated(); + query_as!( + RawUserShare, + r#" +DELETE FROM user_shares +WHERE id = ?1 +RETURNING id as "id: uuid::fmt::Hyphenated", user_id as "user_id!", resource as "resource!", created_at as "created_at!" + "#, + id + ) + .fetch_optional(&self.pool) + .await? + .map(UserShare::try_from) + .transpose() + } } #[cfg(test)] mod tests { use crate::{ security::StoredCredentials, - tests::{mock_db, mock_user, MockUserBuilder}, + tests::{mock_db, mock_user, mock_user_with_id, MockUserBuilder}, users::{ - InternalUserDataNamespace, PublicUserDataNamespace, User, UserData, UserDataNamespace, - UserId, + InternalUserDataNamespace, PublicUserDataNamespace, SharedResource, User, UserData, + UserDataNamespace, UserId, UserShare, UserShareId, }, }; use insta::assert_debug_snapshot; @@ -296,6 +383,7 @@ mod tests { time::Duration, }; use time::OffsetDateTime; + use uuid::uuid; #[actix_rt::test] async fn can_add_and_retrieve_users() -> anyhow::Result<()> { @@ -1089,4 +1177,158 @@ mod tests { Ok(()) } + + #[actix_rt::test] + async fn can_add_and_retrieve_user_shares() -> anyhow::Result<()> { + let user_shares = vec![ + UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000001")), + user_id: 1.try_into()?, + resource: SharedResource::content_security_policy("my-policy"), + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }, + UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000002")), + user_id: 2.try_into()?, + resource: SharedResource::content_security_policy("my-policy"), + created_at: OffsetDateTime::from_unix_timestamp(946720801)?, + }, + ]; + + let db = mock_db().await?; + db.insert_user(mock_user_with_id(1)?).await?; + db.insert_user(mock_user_with_id(2)?).await?; + + for user_share in user_shares.iter() { + assert!(db.get_user_share(user_share.id).await?.is_none()); + } + + // 1. Insert new user shares. + for user_share in user_shares.iter() { + db.insert_user_share(user_share).await?; + } + + // 2. Make sure they were inserted correctly. + for user_share in user_shares { + assert_eq!( + db.get_user_share(user_share.id).await?, + Some(user_share.clone()) + ); + } + + Ok(()) + } + + #[actix_rt::test] + async fn can_retrieve_user_shares_by_resource() -> anyhow::Result<()> { + let user_shares = vec![ + UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000001")), + user_id: 1.try_into()?, + resource: SharedResource::content_security_policy("my-policy"), + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }, + UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000002")), + user_id: 2.try_into()?, + resource: SharedResource::content_security_policy("my-policy"), + created_at: OffsetDateTime::from_unix_timestamp(946720801)?, + }, + ]; + + let db = mock_db().await?; + db.insert_user(mock_user_with_id(1)?).await?; + db.insert_user(mock_user_with_id(2)?).await?; + + // 1. Insert new user shares. + for user_share in user_shares.iter() { + db.insert_user_share(user_share).await?; + } + + assert_eq!( + db.get_user_share_by_resource(user_shares[0].user_id, &user_shares[0].resource) + .await?, + Some(user_shares[0].clone()) + ); + assert_eq!( + db.get_user_share_by_resource(user_shares[1].user_id, &user_shares[1].resource) + .await?, + Some(user_shares[1].clone()) + ); + + assert!(db + .get_user_share_by_resource(3.try_into()?, &user_shares[0].resource) + .await? + .is_none()); + assert!(db + .get_user_share_by_resource( + user_shares[0].user_id, + &SharedResource::content_security_policy("not-my-policy") + ) + .await? + .is_none()); + + Ok(()) + } + + #[actix_rt::test] + async fn can_remove_user_shares() -> anyhow::Result<()> { + let user_shares = vec![ + UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000001")), + user_id: 1.try_into()?, + resource: SharedResource::content_security_policy("my-policy"), + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }, + UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000002")), + user_id: 2.try_into()?, + resource: SharedResource::content_security_policy("my-policy"), + created_at: OffsetDateTime::from_unix_timestamp(946720801)?, + }, + ]; + + let db = mock_db().await?; + db.insert_user(mock_user_with_id(1)?).await?; + db.insert_user(mock_user_with_id(2)?).await?; + + for user_share in user_shares.iter() { + assert!(db.get_user_share(user_share.id).await?.is_none()); + } + + // 1. Insert new user shares. + for user_share in user_shares.iter() { + db.insert_user_share(user_share).await?; + } + + // 2. Make sure they were inserted correctly. + for user_share in user_shares.iter() { + assert_eq!( + db.get_user_share(user_share.id).await?, + Some(user_share.clone()) + ); + } + + // 3. Remove the first user share. + assert_eq!( + db.remove_user_share(user_shares[0].id).await?, + Some(user_shares[0].clone()) + ); + assert!(db.get_user_share(user_shares[0].id).await?.is_none()); + assert_eq!( + db.get_user_share(user_shares[1].id).await?, + Some(user_shares[1].clone()) + ); + + // 3. Remove the last user share. + assert_eq!( + db.remove_user_share(user_shares[1].id).await?, + Some(user_shares[1].clone()) + ); + for user_share in user_shares { + assert!(db.get_user_share(user_share.id).await?.is_none()); + } + + Ok(()) + } } diff --git a/src/users/database_ext/raw_user_share.rs b/src/users/database_ext/raw_user_share.rs new file mode 100644 index 0000000..5c73e55 --- /dev/null +++ b/src/users/database_ext/raw_user_share.rs @@ -0,0 +1,120 @@ +use crate::users::UserShare; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub(super) struct RawUserShare { + pub id: uuid::fmt::Hyphenated, + pub user_id: i64, + pub resource: Vec, + pub created_at: i64, +} + +impl TryFrom for UserShare { + type Error = anyhow::Error; + + fn try_from(raw_user_share: RawUserShare) -> Result { + Ok(UserShare { + id: (*raw_user_share.id.as_uuid()).into(), + user_id: raw_user_share.user_id.try_into()?, + resource: postcard::from_bytes(&raw_user_share.resource)?, + created_at: OffsetDateTime::from_unix_timestamp(raw_user_share.created_at)?, + }) + } +} + +impl TryFrom<&UserShare> for RawUserShare { + type Error = anyhow::Error; + + fn try_from(user_share: &UserShare) -> Result { + Ok(RawUserShare { + id: Uuid::from(&user_share.id).into(), + user_id: *user_share.user_id, + resource: postcard::to_stdvec(&user_share.resource)?, + created_at: user_share.created_at.unix_timestamp(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::RawUserShare; + use crate::users::{SharedResource, UserShare}; + use insta::assert_debug_snapshot; + use time::OffsetDateTime; + use uuid::uuid; + + #[test] + fn can_convert_into_user_share() -> anyhow::Result<()> { + assert_debug_snapshot!(UserShare::try_from(RawUserShare { + id: uuid!("00000000-0000-0000-0000-000000000001").hyphenated(), + user_id: 1, + resource: vec![0, 9, 109, 121, 45, 112, 111, 108, 105, 99, 121], + // January 1, 2000 10:00:00 + created_at: 946720800, + })?, @r###" + UserShare { + id: UserShareId( + 00000000-0000-0000-0000-000000000001, + ), + user_id: UserId( + 1, + ), + resource: ContentSecurityPolicy { + policy_name: "my-policy", + }, + created_at: 2000-01-01 10:00:00.0 +00:00:00, + } + "###); + + Ok(()) + } + + #[test] + fn can_convert_into_raw_user_share() -> anyhow::Result<()> { + assert_debug_snapshot!(RawUserShare::try_from(&UserShare { + id: uuid!("00000000-0000-0000-0000-000000000001").into(), + user_id: 1.try_into()?, + resource: SharedResource::content_security_policy("my-policy"), + // January 1, 2000 10:00:00 + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + })?, @r###" + RawUserShare { + id: Hyphenated( + 00000000-0000-0000-0000-000000000001, + ), + user_id: 1, + resource: [ + 0, + 9, + 109, + 121, + 45, + 112, + 111, + 108, + 105, + 99, + 121, + ], + created_at: 946720800, + } + "###); + + Ok(()) + } + + #[test] + fn fails_if_malformed() -> anyhow::Result<()> { + assert!(UserShare::try_from(RawUserShare { + id: uuid!("00000000-0000-0000-0000-000000000001").hyphenated(), + user_id: -1, + resource: postcard::to_stdvec(&SharedResource::content_security_policy("my-policy"))?, + // January 1, 2000 10:00:00 + created_at: 946720800, + }) + .is_err()); + + Ok(()) + } +} diff --git a/src/users/user_share.rs b/src/users/user_share.rs new file mode 100644 index 0000000..b40045c --- /dev/null +++ b/src/users/user_share.rs @@ -0,0 +1,69 @@ +mod shared_resource; +mod user_share_id; + +use crate::users::UserId; +use serde::Serialize; +use time::OffsetDateTime; + +pub use self::{ + shared_resource::{ClientSharedResource, SharedResource}, + user_share_id::UserShareId, +}; + +/// Represents a shared user resource. +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct UserShare { + pub id: UserShareId, + pub user_id: UserId, + pub resource: SharedResource, + pub created_at: OffsetDateTime, +} + +/// A special version of UserShare that can be safely serialized for the client side since not +/// all Serde attributes we need can be serialized with postcard (main serialization format). It +/// also excludes the user ID since it shouldn't be exposed to the client side. +#[derive(Serialize, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ClientUserShare { + pub id: UserShareId, + pub resource: ClientSharedResource, + #[serde(with = "time::serde::timestamp")] + pub created_at: OffsetDateTime, +} + +impl From for ClientUserShare { + fn from(user_share: UserShare) -> Self { + Self { + id: user_share.id, + resource: user_share.resource.into(), + created_at: user_share.created_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::{ClientSharedResource, ClientUserShare}; + use crate::users::{SharedResource, UserId, UserShare, UserShareId}; + + #[test] + fn can_create_client_user_share() { + let user_share_id = UserShareId::new(); + let resource = SharedResource::content_security_policy("my-policy"); + let created_at = time::OffsetDateTime::now_utc(); + + assert_eq!( + ClientUserShare::from(UserShare { + id: user_share_id, + user_id: UserId::empty(), + resource: resource.clone(), + created_at, + }), + ClientUserShare { + id: user_share_id, + resource: ClientSharedResource::from(resource), + created_at, + } + ); + } +} diff --git a/src/users/user_share/shared_resource.rs b/src/users/user_share/shared_resource.rs new file mode 100644 index 0000000..b370614 --- /dev/null +++ b/src/users/user_share/shared_resource.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +/// Describes a resource that can be shared with other users. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum SharedResource { + ContentSecurityPolicy { policy_name: String }, +} + +impl SharedResource { + /// Creates a new shared resource referencing a user content security policy. + pub fn content_security_policy>(policy_name: T) -> SharedResource { + SharedResource::ContentSecurityPolicy { + policy_name: policy_name.into(), + } + } +} + +/// A special version of SharedResource that can be safely serialized for the client side since not +/// all Serde attributes we need can be serialized with postcard (main serialization format). +#[derive(Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "type")] +pub enum ClientSharedResource { + #[serde(rename_all = "camelCase")] + ContentSecurityPolicy { policy_name: String }, +} + +impl From for ClientSharedResource { + fn from(value: SharedResource) -> Self { + match value { + SharedResource::ContentSecurityPolicy { policy_name } => { + Self::ContentSecurityPolicy { policy_name } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{ClientSharedResource, SharedResource}; + + #[test] + fn can_create_csp_shared_resource() { + assert_eq!( + SharedResource::content_security_policy("my-policy"), + SharedResource::ContentSecurityPolicy { + policy_name: "my-policy".to_string() + } + ); + } + + #[test] + fn can_create_client_shared_resource() { + assert_eq!( + ClientSharedResource::from(SharedResource::content_security_policy("my-policy")), + ClientSharedResource::ContentSecurityPolicy { + policy_name: "my-policy".to_string() + } + ); + } +} diff --git a/src/users/user_share/user_share_id.rs b/src/users/user_share/user_share_id.rs new file mode 100644 index 0000000..81be8c2 --- /dev/null +++ b/src/users/user_share/user_share_id.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; +use std::{ops::Deref, str::FromStr}; +use uuid::Uuid; + +/// Represents unique identifier of the shared resource. +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Copy, Hash)] +pub struct UserShareId(Uuid); +impl UserShareId { + /// Creates a new unique user share ID. + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl Default for UserShareId { + fn default() -> Self { + Self::new() + } +} + +impl From for UserShareId { + fn from(value: Uuid) -> Self { + Self(value) + } +} + +impl From<&UserShareId> for Uuid { + fn from(value: &UserShareId) -> Self { + value.0 + } +} + +impl FromStr for UserShareId { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(Uuid::parse_str(s)?)) + } +} + +impl Deref for UserShareId { + type Target = Uuid; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use crate::users::UserShareId; + use uuid::{uuid, Uuid, Version}; + + #[test] + fn creation() { + let user_share_id = UserShareId::new(); + let underlying_uuid = Uuid::from(&user_share_id); + assert_eq!(underlying_uuid.get_version(), Some(Version::Random)); + assert!(!underlying_uuid.is_nil()); + + let user_share_id = UserShareId::default(); + let underlying_uuid = Uuid::from(&user_share_id); + assert_eq!(underlying_uuid.get_version(), Some(Version::Random)); + assert!(!underlying_uuid.is_nil()); + } + + #[test] + fn conversion() { + assert_eq!( + *UserShareId::from(uuid!("00000000-0000-0000-0000-000000000001")), + uuid!("00000000-0000-0000-0000-000000000001") + ); + + assert_eq!( + Uuid::from(&UserShareId::from(uuid!( + "00000000-0000-0000-0000-000000000001" + ))), + uuid!("00000000-0000-0000-0000-000000000001") + ); + } + + #[test] + fn parsing() -> anyhow::Result<()> { + assert_eq!( + "00000000-0000-0000-0000-000000000001".parse::()?, + UserShareId::from(uuid!("00000000-0000-0000-0000-000000000001")) + ); + + Ok(()) + } +} diff --git a/src/utils.rs b/src/utils.rs index b85395f..1a9df5c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,7 @@ pub mod api_ext; mod certificates; mod database_ext; +mod user_share_ext; mod util; mod utils_action; mod utils_action_result; diff --git a/src/utils/user_share_ext.rs b/src/utils/user_share_ext.rs new file mode 100644 index 0000000..259ce4f --- /dev/null +++ b/src/utils/user_share_ext.rs @@ -0,0 +1,99 @@ +use crate::{ + users::{SharedResource, UserShare}, + utils::{UtilsAction, UtilsWebSecurityAction}, +}; + +impl UserShare { + /// Checks if the user share is authorized to perform the specified action. + pub fn is_action_authorized(&self, action: &UtilsAction) -> bool { + match (&self.resource, action) { + // Any user can access and serialize content of the shared content security policy. + ( + SharedResource::ContentSecurityPolicy { + policy_name: resource_policy_name, + }, + UtilsAction::WebSecurity(UtilsWebSecurityAction::GetContentSecurityPolicy { + policy_name, + }) + | UtilsAction::WebSecurity(UtilsWebSecurityAction::SerializeContentSecurityPolicy { + policy_name, + .. + }), + ) if resource_policy_name == policy_name => true, + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + users::{SharedResource, UserId, UserShare}, + utils::{ + ContentSecurityPolicy, ContentSecurityPolicyDirective, ContentSecurityPolicySource, + UtilsAction, UtilsWebSecurityAction, + }, + }; + use time::OffsetDateTime; + + #[test] + fn properly_checks_action_authorization_for_shared_csp() { + let user_share = UserShare { + id: Default::default(), + user_id: UserId::empty(), + resource: SharedResource::content_security_policy("my-policy"), + created_at: OffsetDateTime::now_utc(), + }; + + let unauthorized_actions = vec![ + UtilsAction::WebSecurity(UtilsWebSecurityAction::SaveContentSecurityPolicy { + policy: ContentSecurityPolicy { + name: "".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }, + }), + UtilsAction::WebSecurity(UtilsWebSecurityAction::RemoveContentSecurityPolicy { + policy_name: "not-my-policy".to_string(), + }), + UtilsAction::WebSecurity(UtilsWebSecurityAction::ShareContentSecurityPolicy { + policy_name: "not-my-policy".to_string(), + }), + UtilsAction::WebSecurity(UtilsWebSecurityAction::UnshareContentSecurityPolicy { + policy_name: "not-my-policy".to_string(), + }), + UtilsAction::WebSecurity(UtilsWebSecurityAction::GetContentSecurityPolicy { + policy_name: "not-my-policy".to_string(), + }), + UtilsAction::WebSecurity(UtilsWebSecurityAction::SerializeContentSecurityPolicy { + policy_name: "not-my-policy".to_string(), + source: ContentSecurityPolicySource::Meta, + }), + UtilsAction::WebSecurity(UtilsWebSecurityAction::SerializeContentSecurityPolicy { + policy_name: "not-my-policy".to_string(), + source: ContentSecurityPolicySource::Header, + }), + ]; + for action in unauthorized_actions { + assert!(!user_share.is_action_authorized(&action)); + } + + let authorized_actions = vec![ + UtilsAction::WebSecurity(UtilsWebSecurityAction::GetContentSecurityPolicy { + policy_name: "my-policy".to_string(), + }), + UtilsAction::WebSecurity(UtilsWebSecurityAction::SerializeContentSecurityPolicy { + policy_name: "my-policy".to_string(), + source: ContentSecurityPolicySource::Meta, + }), + UtilsAction::WebSecurity(UtilsWebSecurityAction::SerializeContentSecurityPolicy { + policy_name: "my-policy".to_string(), + source: ContentSecurityPolicySource::Header, + }), + ]; + for action in authorized_actions { + assert!(user_share.is_action_authorized(&action)); + } + } +} diff --git a/src/utils/web_security.rs b/src/utils/web_security.rs index afee146..dee365d 100644 --- a/src/utils/web_security.rs +++ b/src/utils/web_security.rs @@ -1,3 +1,4 @@ +mod api_ext; mod csp; mod utils_web_security_action; mod utils_web_security_action_result; diff --git a/src/utils/web_security/api_ext.rs b/src/utils/web_security/api_ext.rs new file mode 100644 index 0000000..c7234ba --- /dev/null +++ b/src/utils/web_security/api_ext.rs @@ -0,0 +1,550 @@ +use crate::{ + api::Api, + network::{DnsResolver, EmailTransport}, + users::{ + DictionaryDataUserDataSetter, PublicUserDataNamespace, SharedResource, UserData, UserId, + UserShare, + }, + utils::ContentSecurityPolicy, +}; +use std::collections::BTreeMap; +use time::OffsetDateTime; + +pub struct WebSecurityApi<'a, DR: DnsResolver, ET: EmailTransport> { + api: &'a Api, +} + +impl<'a, DR: DnsResolver, ET: EmailTransport> WebSecurityApi<'a, DR, ET> { + /// Creates WebSecurity API. + pub fn new(api: &'a Api) -> Self { + Self { api } + } + + /// Returns content security policy by its name. + pub async fn get_content_security_policy( + &self, + user_id: UserId, + policy_name: &str, + ) -> anyhow::Result> { + let users_api = self.api.users(); + Ok(users_api + .get_data::>( + user_id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await? + .and_then(|mut map| map.value.remove(policy_name))) + } + + /// Upserts content security policy. + pub async fn upsert_content_security_policy( + &self, + user_id: UserId, + policy: ContentSecurityPolicy, + ) -> anyhow::Result<()> { + DictionaryDataUserDataSetter::upsert( + &self.api.db, + PublicUserDataNamespace::ContentSecurityPolicies, + UserData::new( + user_id, + [(policy.name.clone(), Some(policy))] + .into_iter() + .collect::>(), + OffsetDateTime::now_utc(), + ), + ) + .await?; + + Ok(()) + } + + /// Removes content security policy by its name and returns it. + pub async fn remove_content_security_policy( + &self, + user_id: UserId, + policy_name: &str, + ) -> anyhow::Result<()> { + // 1. Unshare the policy, if it's shared. + self.unshare_content_security_policy(user_id, policy_name) + .await?; + + DictionaryDataUserDataSetter::upsert( + &self.api.db, + PublicUserDataNamespace::ContentSecurityPolicies, + UserData::new( + user_id, + [(policy_name.to_string(), None)] + .into_iter() + .collect::>>(), + OffsetDateTime::now_utc(), + ), + ) + .await + } + + /// Shares content security policy by its name. + pub async fn share_content_security_policy( + &self, + user_id: UserId, + policy_name: &str, + ) -> anyhow::Result { + let users_api = self.api.users(); + let policy_resource = SharedResource::ContentSecurityPolicy { + policy_name: policy_name.to_string(), + }; + + // Return early if policy is already shared. + if let Some(user_share) = users_api + .get_user_share_by_resource(user_id, &policy_resource) + .await? + { + return Ok(user_share); + } + + // Ensure that policy exists. + if self + .get_content_security_policy(user_id, policy_name) + .await? + .is_none() + { + log::error!( + "Content security policy with name '{}' doesn't exist.", + policy_name + ); + anyhow::bail!( + "Content security policy with name '{}' doesn't exist.", + policy_name + ); + } + + // Create new user share. + let user_share = UserShare { + id: Default::default(), + user_id, + resource: policy_resource, + // Preserve timestamp only up to seconds. + created_at: OffsetDateTime::from_unix_timestamp( + OffsetDateTime::now_utc().unix_timestamp(), + )?, + }; + users_api + .insert_user_share(&user_share) + .await + .map(|_| user_share) + } + + /// Unshares content security policy by its name. + pub async fn unshare_content_security_policy( + &self, + user_id: UserId, + policy_name: &str, + ) -> anyhow::Result> { + let users_api = self.api.users(); + + // Check if policy is shared. + let Some(user_share) = users_api + .get_user_share_by_resource( + user_id, + &SharedResource::ContentSecurityPolicy { + policy_name: policy_name.to_string(), + }, + ) + .await? + else { + return Ok(None); + }; + + users_api.remove_user_share(user_share.id).await + } +} + +impl Api { + /// Returns an API to work with web scraping data. + pub fn web_security(&self) -> WebSecurityApi<'_, DR, ET> { + WebSecurityApi::new(self) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + tests::{mock_api, mock_user}, + users::PublicUserDataNamespace, + utils::{ + web_security::api_ext::WebSecurityApi, ContentSecurityPolicy, + ContentSecurityPolicyDirective, + }, + }; + use std::collections::HashMap; + + #[actix_rt::test] + async fn properly_saves_new_policies() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let web_security = WebSecurityApi::new(&api); + let policy_one = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + web_security + .upsert_content_security_policy(mock_user.id, policy_one.clone()) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await? + .unwrap(); + assert_eq!( + user_data.value, + [(policy_one.name.clone(), policy_one.clone())] + .into_iter() + .collect::>() + ); + assert_eq!( + web_security + .get_content_security_policy(mock_user.id, &policy_one.name) + .await?, + Some(policy_one.clone()) + ); + + let policy_two = ContentSecurityPolicy { + name: "policy-two".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'none'".to_string()].into_iter().collect(), + )], + }; + web_security + .upsert_content_security_policy(mock_user.id, policy_two.clone()) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await? + .unwrap(); + assert_eq!( + user_data.value, + [ + (policy_one.name.clone(), policy_one.clone()), + (policy_two.name.clone(), policy_two.clone()) + ] + .into_iter() + .collect::>() + ); + + Ok(()) + } + + #[actix_rt::test] + async fn properly_updates_existing_policies() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let web_security = WebSecurityApi::new(&api); + let policy_one = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + web_security + .upsert_content_security_policy(mock_user.id, policy_one.clone()) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await? + .unwrap(); + assert_eq!( + user_data.value, + [(policy_one.name.clone(), policy_one.clone())] + .into_iter() + .collect::>() + ); + + let policy_one = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'none'".to_string()].into_iter().collect(), + )], + }; + web_security + .upsert_content_security_policy(mock_user.id, policy_one.clone()) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await? + .unwrap(); + assert_eq!( + user_data.value, + [(policy_one.name.clone(), policy_one.clone())] + .into_iter() + .collect::>() + ); + + Ok(()) + } + + #[actix_rt::test] + async fn properly_removes_policies() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy_one = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + let policy_two = ContentSecurityPolicy { + name: "policy-two".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'none'".to_string()].into_iter().collect(), + )], + }; + + let web_security = WebSecurityApi::new(&api); + web_security + .upsert_content_security_policy(mock_user.id, policy_one.clone()) + .await?; + web_security + .upsert_content_security_policy(mock_user.id, policy_two.clone()) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await? + .unwrap(); + assert_eq!( + user_data.value, + [ + (policy_one.name.clone(), policy_one.clone()), + (policy_two.name.clone(), policy_two.clone()) + ] + .into_iter() + .collect::>() + ); + + web_security + .remove_content_security_policy(mock_user.id, &policy_one.name) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await? + .unwrap(); + assert_eq!( + user_data.value, + [(policy_two.name.clone(), policy_two.clone())] + .into_iter() + .collect::>() + ); + + web_security + .remove_content_security_policy(mock_user.id, &policy_two.name) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await?; + assert!(user_data.is_none()); + + Ok(()) + } + + #[actix_rt::test] + async fn properly_shares_policy() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy_one = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + + // Create and share policy. + let web_security = WebSecurityApi::new(&api); + web_security + .upsert_content_security_policy(mock_user.id, policy_one.clone()) + .await?; + let policy_share_one = web_security + .share_content_security_policy(mock_user.id, &policy_one.name) + .await?; + + assert_eq!( + api.users().get_user_share(policy_share_one.id).await?, + Some(policy_share_one.clone()) + ); + + // Repetitive sharing should return the same share. + let policy_share_two = web_security + .share_content_security_policy(mock_user.id, &policy_one.name) + .await?; + + assert_eq!(policy_share_one, policy_share_two,); + assert_eq!( + api.users().get_user_share(policy_share_one.id).await?, + Some(policy_share_one.clone()) + ); + + Ok(()) + } + + #[actix_rt::test] + async fn properly_unshares_policy() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy_one = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + + // Create, share, and unshare policy. + let web_security = WebSecurityApi::new(&api); + web_security + .upsert_content_security_policy(mock_user.id, policy_one.clone()) + .await?; + let policy_share_one = web_security + .share_content_security_policy(mock_user.id, &policy_one.name) + .await?; + assert_eq!( + web_security + .unshare_content_security_policy(mock_user.id, &policy_one.name) + .await?, + Some(policy_share_one.clone()) + ); + + assert!(api + .users() + .get_user_share(policy_share_one.id) + .await? + .is_none()); + + // Sharing again should return different share. + let policy_share_two = web_security + .share_content_security_policy(mock_user.id, &policy_one.name) + .await?; + assert_ne!(policy_share_one.id, policy_share_two.id); + + assert_eq!( + web_security + .unshare_content_security_policy(mock_user.id, &policy_one.name) + .await?, + Some(policy_share_two.clone()) + ); + + assert!(api + .users() + .get_user_share(policy_share_two.id) + .await? + .is_none()); + + Ok(()) + } + + #[actix_rt::test] + async fn properly_unshares_policy_when_policy_is_removed() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy_one = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + + // Create and share policy. + let web_security = WebSecurityApi::new(&api); + web_security + .upsert_content_security_policy(mock_user.id, policy_one.clone()) + .await?; + let policy_share = web_security + .share_content_security_policy(mock_user.id, &policy_one.name) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await? + .unwrap(); + assert_eq!( + user_data.value, + [(policy_one.name.clone(), policy_one.clone()),] + .into_iter() + .collect::>() + ); + + assert_eq!( + api.users().get_user_share(policy_share.id).await?, + Some(policy_share.clone()) + ); + + web_security + .remove_content_security_policy(mock_user.id, &policy_one.name) + .await?; + + let user_data = api + .users() + .get_data::>( + mock_user.id, + PublicUserDataNamespace::ContentSecurityPolicies, + ) + .await?; + assert!(user_data.is_none()); + assert!(api.users().get_user_share(policy_share.id).await?.is_none(),); + + Ok(()) + } +} diff --git a/src/utils/web_security/csp/content_security_policies/content_security_policy.rs b/src/utils/web_security/csp/content_security_policies/content_security_policy.rs index f6dc44d..6e59d91 100644 --- a/src/utils/web_security/csp/content_security_policies/content_security_policy.rs +++ b/src/utils/web_security/csp/content_security_policies/content_security_policy.rs @@ -1,6 +1,9 @@ -use crate::utils::ContentSecurityPolicyDirective; +use crate::utils::{ + utils_action_validation::MAX_UTILS_ENTITY_NAME_LENGTH, ContentSecurityPolicyDirective, +}; use serde::{Deserialize, Serialize}; +/// Represents content security policy (CSP) with the arbitrary name. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ContentSecurityPolicy { #[serde(rename = "n")] @@ -9,6 +12,15 @@ pub struct ContentSecurityPolicy { pub directives: Vec, } +impl ContentSecurityPolicy { + /// Performs basic content security policy validation. + pub fn is_valid(&self) -> bool { + !self.name.is_empty() + && self.name.len() <= MAX_UTILS_ENTITY_NAME_LENGTH + && !self.directives.is_empty() + } +} + #[cfg(test)] mod tests { use crate::utils::{ContentSecurityPolicy, ContentSecurityPolicyDirective}; @@ -69,4 +81,27 @@ mod tests { Ok(()) } + + #[test] + fn is_valid() { + assert!(!ContentSecurityPolicy { + name: "".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect() + )] + } + .is_valid()); + assert!(!ContentSecurityPolicy { + name: "some-name".to_string(), + directives: vec![] + } + .is_valid()); + assert!(ContentSecurityPolicy { + name: "some-name".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect() + )] + } + .is_valid()); + } } diff --git a/src/utils/web_security/utils_web_security_action.rs b/src/utils/web_security/utils_web_security_action.rs index b74805c..2a28bf9 100644 --- a/src/utils/web_security/utils_web_security_action.rs +++ b/src/utils/web_security/utils_web_security_action.rs @@ -1,7 +1,7 @@ use crate::{ api::Api, network::{DnsResolver, EmailTransport}, - users::{PublicUserDataNamespace, User}, + users::{ClientUserShare, SharedResource, User}, utils::{ utils_action_validation::MAX_UTILS_ENTITY_NAME_LENGTH, ContentSecurityPolicy, ContentSecurityPolicyDirective, ContentSecurityPolicySource, UtilsWebSecurityActionResult, @@ -9,12 +9,22 @@ use crate::{ }; use anyhow::anyhow; use serde::Deserialize; -use std::collections::BTreeMap; +#[allow(clippy::enum_variant_names)] #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] #[serde(tag = "type", content = "value")] pub enum UtilsWebSecurityAction { + #[serde(rename_all = "camelCase")] + GetContentSecurityPolicy { policy_name: String }, + #[serde(rename_all = "camelCase")] + SaveContentSecurityPolicy { policy: ContentSecurityPolicy }, + #[serde(rename_all = "camelCase")] + RemoveContentSecurityPolicy { policy_name: String }, + #[serde(rename_all = "camelCase")] + ShareContentSecurityPolicy { policy_name: String }, + #[serde(rename_all = "camelCase")] + UnshareContentSecurityPolicy { policy_name: String }, #[serde(rename_all = "camelCase")] SerializeContentSecurityPolicy { policy_name: String, @@ -26,7 +36,11 @@ impl UtilsWebSecurityAction { /// Validates action parameters and throws if action parameters aren't valid. pub fn validate(&self) -> anyhow::Result<()> { match self { - UtilsWebSecurityAction::SerializeContentSecurityPolicy { policy_name, .. } => { + UtilsWebSecurityAction::SerializeContentSecurityPolicy { policy_name, .. } + | UtilsWebSecurityAction::GetContentSecurityPolicy { policy_name } + | UtilsWebSecurityAction::RemoveContentSecurityPolicy { policy_name } + | UtilsWebSecurityAction::ShareContentSecurityPolicy { policy_name } + | UtilsWebSecurityAction::UnshareContentSecurityPolicy { policy_name } => { if policy_name.is_empty() { anyhow::bail!("Policy name cannot be empty"); } @@ -38,6 +52,11 @@ impl UtilsWebSecurityAction { ); } } + UtilsWebSecurityAction::SaveContentSecurityPolicy { policy } => { + if !policy.is_valid() { + anyhow::bail!("Policy is not valid"); + } + } } Ok(()) @@ -48,39 +67,71 @@ impl UtilsWebSecurityAction { user: User, api: &Api, ) -> anyhow::Result { + let web_security = api.web_security(); match self { + UtilsWebSecurityAction::GetContentSecurityPolicy { policy_name } => { + let users = api.users(); + Ok(UtilsWebSecurityActionResult::get( + web_security + .get_content_security_policy(user.id, &policy_name) + .await?, + users + .get_user_share_by_resource( + user.id, + &SharedResource::content_security_policy(policy_name), + ) + .await? + .map(ClientUserShare::from), + )) + } + UtilsWebSecurityAction::SaveContentSecurityPolicy { policy } => web_security + .upsert_content_security_policy(user.id, policy) + .await + .map(|_| UtilsWebSecurityActionResult::save()), + UtilsWebSecurityAction::RemoveContentSecurityPolicy { policy_name } => web_security + .remove_content_security_policy(user.id, &policy_name) + .await + .map(|_| UtilsWebSecurityActionResult::remove()), + UtilsWebSecurityAction::ShareContentSecurityPolicy { policy_name } => web_security + .share_content_security_policy(user.id, &policy_name) + .await + .map(|user_share| { + UtilsWebSecurityActionResult::share(ClientUserShare::from(user_share)) + }), + UtilsWebSecurityAction::UnshareContentSecurityPolicy { policy_name } => web_security + .unshare_content_security_policy(user.id, &policy_name) + .await + .map(|user_share| user_share.map(ClientUserShare::from)) + .map(UtilsWebSecurityActionResult::unshare), UtilsWebSecurityAction::SerializeContentSecurityPolicy { policy_name, source, } => { - let policy = api - .users() - .get_data::>( - user.id, - PublicUserDataNamespace::ContentSecurityPolicies, - ) + let policy = web_security + .get_content_security_policy(user.id, &policy_name) .await? - .and_then(|mut map| map.value.remove(&policy_name)) .ok_or_else(|| { anyhow!( - "Cannot find content security policy with name: {}", + "Cannot find user ({}) content security policy with the following name: {}", + *user.id, policy_name ) })?; - let policy = match source { - ContentSecurityPolicySource::Meta => serialize_directives( - policy - .directives - .into_iter() - .filter(|directive| directive.is_supported_for_source(source)), - )?, - ContentSecurityPolicySource::Header => { - serialize_directives(policy.directives.into_iter())? - } - }; - - Ok(UtilsWebSecurityActionResult::SerializeContentSecurityPolicy { policy, source }) + Ok(UtilsWebSecurityActionResult::serialize( + match source { + ContentSecurityPolicySource::Meta => serialize_directives( + policy + .directives + .into_iter() + .filter(|directive| directive.is_supported_for_source(source)), + )?, + ContentSecurityPolicySource::Header => { + serialize_directives(policy.directives.into_iter())? + } + }, + source, + )) } } } @@ -99,11 +150,13 @@ fn serialize_directives( #[cfg(test)] mod tests { - use super::serialize_directives; - use crate::utils::{ - ContentSecurityPolicyDirective, ContentSecurityPolicySource, UtilsWebSecurityAction, + use crate::{ + tests::{mock_api, mock_user}, + utils::{ + ContentSecurityPolicy, ContentSecurityPolicyDirective, ContentSecurityPolicySource, + UtilsWebSecurityAction, UtilsWebSecurityActionResult, + }, }; - use insta::assert_debug_snapshot; use std::collections::HashSet; #[test] @@ -112,11 +165,88 @@ mod tests { serde_json::from_str::( r#" { - "type": "serializeContentSecurityPolicy", - "value": { "policyName": "policy", "source": "meta" } + "type": "getContentSecurityPolicy", + "value": { "policyName": "policy" } } "# )?, + UtilsWebSecurityAction::GetContentSecurityPolicy { + policy_name: "policy".to_string() + } + ); + + assert_eq!( + serde_json::from_str::( + r#" + { + "type": "saveContentSecurityPolicy", + "value": { "policy": { "n": "policy", "d": [{"n": "child-src", "v": ["'self'", "https://*"]}] } } + } + "# + )?, + UtilsWebSecurityAction::SaveContentSecurityPolicy { + policy: ContentSecurityPolicy { + name: "policy".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string(), "https://*".to_string()] + .into_iter() + .collect() + )] + } + } + ); + + assert_eq!( + serde_json::from_str::( + r#" + { + "type": "removeContentSecurityPolicy", + "value": { "policyName": "policy" } + } + "# + )?, + UtilsWebSecurityAction::RemoveContentSecurityPolicy { + policy_name: "policy".to_string() + } + ); + + assert_eq!( + serde_json::from_str::( + r#" + { + "type": "shareContentSecurityPolicy", + "value": { "policyName": "policy" } + } + "# + )?, + UtilsWebSecurityAction::ShareContentSecurityPolicy { + policy_name: "policy".to_string() + } + ); + + assert_eq!( + serde_json::from_str::( + r#" + { + "type": "unshareContentSecurityPolicy", + "value": { "policyName": "policy" } + } + "# + )?, + UtilsWebSecurityAction::UnshareContentSecurityPolicy { + policy_name: "policy".to_string() + } + ); + + assert_eq!( + serde_json::from_str::( + r#" + { + "type": "serializeContentSecurityPolicy", + "value": { "policyName": "policy", "source": "meta" } + } + "# + )?, UtilsWebSecurityAction::SerializeContentSecurityPolicy { policy_name: "policy".to_string(), source: ContentSecurityPolicySource::Meta, @@ -128,48 +258,302 @@ mod tests { #[test] fn validation() -> anyhow::Result<()> { - assert!(UtilsWebSecurityAction::SerializeContentSecurityPolicy { - policy_name: "a".repeat(100), - source: ContentSecurityPolicySource::Meta, + let get_actions = |policy_name: String| { + vec![ + UtilsWebSecurityAction::GetContentSecurityPolicy { + policy_name: policy_name.clone(), + }, + UtilsWebSecurityAction::RemoveContentSecurityPolicy { + policy_name: policy_name.clone(), + }, + UtilsWebSecurityAction::ShareContentSecurityPolicy { + policy_name: policy_name.clone(), + }, + UtilsWebSecurityAction::UnshareContentSecurityPolicy { + policy_name: policy_name.clone(), + }, + UtilsWebSecurityAction::SerializeContentSecurityPolicy { + policy_name, + source: ContentSecurityPolicySource::Meta, + }, + ] + }; + + for action in get_actions("a".repeat(100)) { + assert!(action.validate().is_ok()); } - .validate() - .is_ok()); - assert_debug_snapshot!(UtilsWebSecurityAction::SerializeContentSecurityPolicy { - policy_name: "".to_string(), - source: ContentSecurityPolicySource::Meta, + for action in get_actions("".to_string()) { + assert_eq!( + action.validate().map_err(|err| err.to_string()), + Err("Policy name cannot be empty".to_string()) + ); } - .validate(), @r###" - Err( - "Policy name cannot be empty", - ) - "###); - - assert_debug_snapshot!(UtilsWebSecurityAction::SerializeContentSecurityPolicy { - policy_name: "a".repeat(101), - source: ContentSecurityPolicySource::Meta, + + for action in get_actions("a".repeat(101)) { + assert_eq!( + action.validate().map_err(|err| err.to_string()), + Err("Policy name cannot be longer than 100 characters".to_string()) + ); } - .validate(), @r###" - Err( - "Policy name cannot be longer than 100 characters", - ) - "###); + + assert!(UtilsWebSecurityAction::SaveContentSecurityPolicy { + policy: ContentSecurityPolicy { + name: "policy".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect() + )] + } + } + .validate() + .is_ok()); + + assert_eq!( + UtilsWebSecurityAction::SaveContentSecurityPolicy { + policy: ContentSecurityPolicy { + name: "".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect() + )] + } + } + .validate() + .map_err(|err| err.to_string()), + Err("Policy is not valid".to_string()) + ); Ok(()) } - #[test] - fn can_serialize_directives() -> anyhow::Result<()> { - let directives = [ - ContentSecurityPolicyDirective::DefaultSrc( - ["'self'".to_string(), "https:".to_string()] - .into_iter() - .collect(), - ), - ContentSecurityPolicyDirective::Sandbox(HashSet::new()), - ContentSecurityPolicyDirective::ReportTo(["prod-csp".to_string()]), - ]; - assert_debug_snapshot!(serialize_directives(directives.into_iter())?, @r###""default-src 'self' https:; sandbox; report-to prod-csp""###); + #[actix_rt::test] + async fn can_retrieve_policy() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + api.web_security() + .upsert_content_security_policy(mock_user.id, policy.clone()) + .await?; + + let action = UtilsWebSecurityAction::GetContentSecurityPolicy { + policy_name: policy.name.clone(), + }; + assert_eq!( + action.handle(mock_user.clone(), &api).await?, + UtilsWebSecurityActionResult::get(Some(policy.clone()), None) + ); + + let policy_share = api + .web_security() + .share_content_security_policy(mock_user.id, &policy.name) + .await?; + + let action = UtilsWebSecurityAction::GetContentSecurityPolicy { + policy_name: policy.name.clone(), + }; + assert_eq!( + action.handle(mock_user.clone(), &api).await?, + UtilsWebSecurityActionResult::get(Some(policy.clone()), Some(policy_share.into())) + ); + + Ok(()) + } + + #[actix_rt::test] + async fn can_remove_policy() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + api.web_security() + .upsert_content_security_policy(mock_user.id, policy.clone()) + .await?; + + let action = UtilsWebSecurityAction::RemoveContentSecurityPolicy { + policy_name: policy.name.clone(), + }; + assert_eq!( + action.handle(mock_user.clone(), &api).await?, + UtilsWebSecurityActionResult::remove() + ); + + assert!(api + .web_security() + .get_content_security_policy(mock_user.id, &policy.name) + .await? + .is_none()); + + Ok(()) + } + + #[actix_rt::test] + async fn can_share_policy() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + api.web_security() + .upsert_content_security_policy(mock_user.id, policy.clone()) + .await?; + + let action = UtilsWebSecurityAction::ShareContentSecurityPolicy { + policy_name: policy.name.clone(), + }; + let result = action.handle(mock_user.clone(), &api).await?; + + let policy_share = api + .web_security() + .share_content_security_policy(mock_user.id, &policy.name) + .await?; + assert_eq!( + result, + UtilsWebSecurityActionResult::share(policy_share.into()) + ); + + Ok(()) + } + + #[actix_rt::test] + async fn can_unshare_policy() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + api.web_security() + .upsert_content_security_policy(mock_user.id, policy.clone()) + .await?; + + let policy_share = api + .web_security() + .share_content_security_policy(mock_user.id, &policy.name) + .await?; + + let action = UtilsWebSecurityAction::UnshareContentSecurityPolicy { + policy_name: policy.name.clone(), + }; + assert_eq!( + action.handle(mock_user.clone(), &api).await?, + UtilsWebSecurityActionResult::unshare(Some(policy_share.clone().into())) + ); + assert!(api + .users() + .get_user_share_by_resource(mock_user.id, &policy_share.resource) + .await? + .is_none()); + + Ok(()) + } + + #[actix_rt::test] + async fn can_save_policy() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }; + assert!(api + .web_security() + .get_content_security_policy(mock_user.id, &policy.name) + .await? + .is_none()); + + let action = UtilsWebSecurityAction::SaveContentSecurityPolicy { + policy: policy.clone(), + }; + assert_eq!( + action.handle(mock_user.clone(), &api).await?, + UtilsWebSecurityActionResult::save() + ); + assert_eq!( + api.web_security() + .get_content_security_policy(mock_user.id, &policy.name) + .await?, + Some(policy) + ); + + Ok(()) + } + + #[actix_rt::test] + async fn can_serialize_policy() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let policy = ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ + ContentSecurityPolicyDirective::DefaultSrc( + ["'self'".to_string(), "https:".to_string()] + .into_iter() + .collect(), + ), + ContentSecurityPolicyDirective::Sandbox(HashSet::new()), + ContentSecurityPolicyDirective::ReportTo(["prod-csp".to_string()]), + ], + }; + api.web_security() + .upsert_content_security_policy(mock_user.id, policy.clone()) + .await?; + + let action = UtilsWebSecurityAction::SerializeContentSecurityPolicy { + policy_name: policy.name.clone(), + source: ContentSecurityPolicySource::Header, + }; + assert_eq!( + action.handle(mock_user.clone(), &api).await?, + UtilsWebSecurityActionResult::serialize( + "default-src 'self' https:; sandbox; report-to prod-csp".to_string(), + ContentSecurityPolicySource::Header + ) + ); + + let action = UtilsWebSecurityAction::SerializeContentSecurityPolicy { + policy_name: policy.name.clone(), + source: ContentSecurityPolicySource::Meta, + }; + assert_eq!( + action.handle(mock_user.clone(), &api).await?, + UtilsWebSecurityActionResult::serialize( + "default-src 'self' https:".to_string(), + ContentSecurityPolicySource::Meta + ) + ); Ok(()) } diff --git a/src/utils/web_security/utils_web_security_action_result.rs b/src/utils/web_security/utils_web_security_action_result.rs index ef5cbb7..aef86cd 100644 --- a/src/utils/web_security/utils_web_security_action_result.rs +++ b/src/utils/web_security/utils_web_security_action_result.rs @@ -1,24 +1,185 @@ -use crate::utils::ContentSecurityPolicySource; +use crate::{ + users::ClientUserShare, + utils::{ContentSecurityPolicy, ContentSecurityPolicySource}, +}; use serde::Serialize; -#[derive(Serialize)] +#[allow(clippy::enum_variant_names)] +#[derive(Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] #[serde(tag = "type", content = "value")] pub enum UtilsWebSecurityActionResult { + #[serde(rename_all = "camelCase")] + GetContentSecurityPolicy { + #[serde(skip_serializing_if = "Option::is_none")] + policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_share: Option, + }, + #[serde(rename_all = "camelCase")] + SaveContentSecurityPolicy, + #[serde(rename_all = "camelCase")] + RemoveContentSecurityPolicy, #[serde(rename_all = "camelCase")] SerializeContentSecurityPolicy { policy: String, source: ContentSecurityPolicySource, }, + #[serde(rename_all = "camelCase")] + ShareContentSecurityPolicy { user_share: ClientUserShare }, + #[serde(rename_all = "camelCase")] + UnshareContentSecurityPolicy { + #[serde(skip_serializing_if = "Option::is_none")] + user_share: Option, + }, +} + +impl UtilsWebSecurityActionResult { + pub fn get(policy: Option, user_share: Option) -> Self { + Self::GetContentSecurityPolicy { policy, user_share } + } + pub fn save() -> Self { + Self::SaveContentSecurityPolicy + } + pub fn remove() -> Self { + Self::RemoveContentSecurityPolicy + } + pub fn share(user_share: ClientUserShare) -> Self { + Self::ShareContentSecurityPolicy { user_share } + } + pub fn unshare(user_share: Option) -> Self { + Self::UnshareContentSecurityPolicy { user_share } + } + + pub fn serialize(serialized_policy: String, source: ContentSecurityPolicySource) -> Self { + Self::SerializeContentSecurityPolicy { + policy: serialized_policy, + source, + } + } } #[cfg(test)] mod tests { - use crate::utils::{ContentSecurityPolicySource, UtilsWebSecurityActionResult}; + use crate::{ + users::{ClientUserShare, SharedResource, UserId, UserShare, UserShareId}, + utils::{ + ContentSecurityPolicy, ContentSecurityPolicyDirective, ContentSecurityPolicySource, + UtilsWebSecurityActionResult, + }, + }; use insta::assert_json_snapshot; + use uuid::uuid; #[test] fn serialization() -> anyhow::Result<()> { + assert_json_snapshot!(UtilsWebSecurityActionResult::get( + Some(ContentSecurityPolicy { + name: "policy-one".to_string(), + directives: vec![ContentSecurityPolicyDirective::ChildSrc( + ["'self'".to_string()].into_iter().collect(), + )], + }), + Some(ClientUserShare::from(UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000001")), + user_id: UserId::empty(), + resource: SharedResource::content_security_policy("policy-one".to_string()), + created_at: time::OffsetDateTime::from_unix_timestamp(123456)?, + })) + ), @r###" + { + "type": "getContentSecurityPolicy", + "value": { + "policy": { + "n": "policy-one", + "d": [ + { + "n": "child-src", + "v": [ + "'self'" + ] + } + ] + }, + "userShare": { + "id": "00000000-0000-0000-0000-000000000001", + "resource": { + "type": "contentSecurityPolicy", + "policyName": "policy-one" + }, + "createdAt": 123456 + } + } + } + "###); + + assert_json_snapshot!(UtilsWebSecurityActionResult::save(), @r###" + { + "type": "saveContentSecurityPolicy" + } + "###); + + assert_json_snapshot!(UtilsWebSecurityActionResult::remove(), @r###" + { + "type": "removeContentSecurityPolicy" + } + "###); + + assert_json_snapshot!(UtilsWebSecurityActionResult::share( + ClientUserShare::from(UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000001")), + user_id: UserId::empty(), + resource: SharedResource::content_security_policy("policy-one".to_string()), + created_at: time::OffsetDateTime::from_unix_timestamp(123456)?, + }) + ), @r###" + { + "type": "shareContentSecurityPolicy", + "value": { + "userShare": { + "id": "00000000-0000-0000-0000-000000000001", + "resource": { + "type": "contentSecurityPolicy", + "policyName": "policy-one" + }, + "createdAt": 123456 + } + } + } + "###); + + assert_json_snapshot!(UtilsWebSecurityActionResult::unshare( + Some(ClientUserShare::from(UserShare { + id: UserShareId::from(uuid!("00000000-0000-0000-0000-000000000001")), + user_id: UserId::empty(), + resource: SharedResource::content_security_policy("policy-one".to_string()), + created_at: time::OffsetDateTime::from_unix_timestamp(123456)?, + })) + ), @r###" + { + "type": "unshareContentSecurityPolicy", + "value": { + "userShare": { + "id": "00000000-0000-0000-0000-000000000001", + "resource": { + "type": "contentSecurityPolicy", + "policyName": "policy-one" + }, + "createdAt": 123456 + } + } + } + "###); + + assert_json_snapshot!(UtilsWebSecurityActionResult::unshare( + None + ), @r###" + { + "type": "unshareContentSecurityPolicy", + "value": {} + } + "###); + assert_json_snapshot!(UtilsWebSecurityActionResult::SerializeContentSecurityPolicy { policy: r###"default-src: 'self'; script-src: https:; report-to csp-prod-group"###.to_string(), source: ContentSecurityPolicySource::Header diff --git a/tools/api/utils/web_security_csp.http b/tools/api/utils/web_security_csp.http new file mode 100644 index 0000000..95074bc --- /dev/null +++ b/tools/api/utils/web_security_csp.http @@ -0,0 +1,53 @@ +### Get content security policy +POST {{host}}/api/utils/action +Authorization: {{api-credentials}} +Accept: application/json +Content-Type: application/json + +{ + "action": { + "type": "webSecurity", + "value": { + "type": "getContentSecurityPolicy", + "value": { + "policyName": "test" + } + } + } +} + +### Share content security policy +POST {{host}}/api/utils/action +Authorization: {{api-credentials}} +Accept: application/json +Content-Type: application/json + +{ + "action": { + "type": "webSecurity", + "value": { + "type": "shareContentSecurityPolicy", + "value": { + "policyName": "test" + } + } + } +} + +### Unshare content security policy +POST {{host}}/api/utils/action +Authorization: {{api-credentials}} +Accept: application/json +Content-Type: application/json + +{ + "action": { + "type": "webSecurity", + "value": { + "type": "unshareContentSecurityPolicy", + "value": { + "policyName": "test" + } + } + } +}