diff --git a/rust/Earthfile b/rust/Earthfile index def1878b47..e194e177ff 100644 --- a/rust/Earthfile +++ b/rust/Earthfile @@ -128,4 +128,4 @@ check-builder-src-cache: # local-ci-run: This step simulates the full CI run for local purposes only. local-ci-run: BUILD +check - BUILD +build \ No newline at end of file + BUILD +build diff --git a/rust/signed_doc/src/providers.rs b/rust/signed_doc/src/providers.rs index 49eb4aeedc..809e1ce4cc 100644 --- a/rust/signed_doc/src/providers.rs +++ b/rust/signed_doc/src/providers.rs @@ -33,6 +33,13 @@ pub trait CatalystSignedDocumentProvider: Send + Sync { id: UuidV7, ) -> impl Future>> + Send; + /// Try to get the first known version of the `CatalystSignedDocument`, `id` and `ver` + /// are equal. + fn try_get_first_doc( + &self, + id: UuidV7, + ) -> impl Future>> + Send; + /// Returns a future threshold value, which is used in the validation of the `ver` /// field that it is not too far in the future. /// If `None` is returned, skips "too far in the future" validation. @@ -116,6 +123,18 @@ pub mod tests { .map(|(_, doc)| doc.clone())) } + async fn try_get_first_doc( + &self, + id: catalyst_types::uuid::UuidV7, + ) -> anyhow::Result> { + Ok(self + .signed_doc + .iter() + .filter(|(doc_ref, _)| doc_ref.id() == &id) + .min_by_key(|(doc_ref, _)| doc_ref.ver().uuid()) + .map(|(_, doc)| doc.clone())) + } + fn future_threshold(&self) -> Option { Some(Duration::from_secs(5)) } diff --git a/rust/signed_doc/src/validator/rules/mod.rs b/rust/signed_doc/src/validator/rules/mod.rs index d004bd75cc..980ff32497 100644 --- a/rust/signed_doc/src/validator/rules/mod.rs +++ b/rust/signed_doc/src/validator/rules/mod.rs @@ -14,7 +14,7 @@ mod content_encoding; mod content_type; mod doc_ref; mod id; -mod original_author; +mod ownership; mod parameters; mod reply; mod section; @@ -30,7 +30,7 @@ pub(crate) use content_encoding::ContentEncodingRule; pub(crate) use content_type::ContentTypeRule; pub(crate) use doc_ref::RefRule; pub(crate) use id::IdRule; -pub(crate) use original_author::OriginalAuthorRule; +pub(crate) use ownership::DocumentOwnershipRule; pub(crate) use parameters::ParametersRule; pub(crate) use reply::ReplyRule; pub(crate) use section::SectionRule; @@ -69,7 +69,7 @@ pub(crate) struct Rules { /// document's signatures validation rule pub(crate) signature: SignatureRule, /// Original Author validation rule. - pub(crate) original_author: OriginalAuthorRule, + pub(crate) ownership: DocumentOwnershipRule, } impl Rules { @@ -96,7 +96,7 @@ impl Rules { self.content.check(doc).boxed(), self.kid.check(doc).boxed(), self.signature.check(doc, provider).boxed(), - self.original_author.check(doc, provider).boxed(), + self.ownership.check(doc, provider).boxed(), ]; let res = futures::future::join_all(rules) @@ -141,7 +141,9 @@ impl Rules { content: ContentRule::new(&doc_spec.payload)?, kid: SignatureKidRule::new(&doc_spec.signers.roles)?, signature: SignatureRule { mutlisig: false }, - original_author: OriginalAuthorRule, + ownership: DocumentOwnershipRule { + allow_collaborators: false, + }, }; let doc_type = doc_spec.doc_type.parse()?; diff --git a/rust/signed_doc/src/validator/rules/original_author.rs b/rust/signed_doc/src/validator/rules/original_author.rs deleted file mode 100644 index 5aac5342d2..0000000000 --- a/rust/signed_doc/src/validator/rules/original_author.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Original Author Validation Rule - -use crate::{providers::CatalystSignedDocumentProvider, CatalystSignedDocument}; - -/// Original Author Validation Rule -#[derive(Debug)] -pub(crate) struct OriginalAuthorRule; - -impl OriginalAuthorRule { - /// Field validation rule - pub(crate) async fn check( - &self, - doc: &CatalystSignedDocument, - provider: &Provider, - ) -> anyhow::Result - where - Provider: CatalystSignedDocumentProvider, - { - let doc_id = doc.doc_id()?; - let Some(original_doc) = provider.try_get_last_doc(doc_id).await? else { - return Ok(true); - }; - let is_valid = original_doc.authors() == doc.authors(); - if !is_valid { - doc.report().functional_validation( - &format!("New document authors must match the authors from the previous version for Document ID {doc_id}"), - &format!( - "Document's signatures must be identical to previous version for Document ID {doc_id}" - ), - ); - } - Ok(is_valid) - } -} - -#[cfg(test)] -mod tests { - use catalyst_types::{ - catalyst_id::{role_index::RoleId, CatalystId}, - uuid::{UuidV4, UuidV7}, - }; - use ed25519_dalek::ed25519::signature::Signer; - use test_case::test_case; - - use super::*; - use crate::{ - builder::tests::Builder, metadata::SupportedField, providers::tests::TestCatalystProvider, - ContentType, - }; - - #[derive(Clone)] - struct CatalystAuthorId { - sk: ed25519_dalek::SigningKey, - kid: CatalystId, - } - - impl CatalystAuthorId { - fn new() -> Self { - let sk = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng); - let pk = sk.verifying_key(); - let kid = CatalystId::new("cardano", None, pk).with_role(RoleId::Role0); - Self { sk, kid } - } - } - - fn doc_builder( - doc_id: UuidV7, - doc_ver: UuidV7, - authors: [CatalystAuthorId; 3], - ) -> (UuidV7, [CatalystAuthorId; 3], CatalystSignedDocument) { - let mut doc_builder = Builder::new() - .with_metadata_field(SupportedField::Id(doc_id)) - .with_metadata_field(SupportedField::Ver(doc_ver)) - .with_metadata_field(SupportedField::Type(UuidV4::new().into())) - .with_metadata_field(SupportedField::ContentType(ContentType::Json)) - .with_content(vec![1, 2, 3]); - for author in &authors { - doc_builder = doc_builder - .add_signature(|m| author.sk.sign(&m).to_vec(), author.kid.clone()) - .unwrap(); - } - (doc_id, authors, doc_builder.build()) - } - - fn gen_authors() -> [CatalystAuthorId; 3] { - [ - CatalystAuthorId::new(), - CatalystAuthorId::new(), - CatalystAuthorId::new(), - ] - } - - fn gen_next_ver_doc( - doc_id: UuidV7, - authors: [CatalystAuthorId; 3], - ) -> CatalystSignedDocument { - let (_, _, new_doc) = doc_builder(doc_id, UuidV7::new(), authors); - new_doc - } - - fn gen_original_doc_and_provider() -> (UuidV7, [CatalystAuthorId; 3], TestCatalystProvider) { - let authors = gen_authors(); - let doc_id = UuidV7::new(); - let doc_ver_1 = UuidV7::new(); - let (_, _, doc_1) = doc_builder(doc_id, doc_ver_1, authors.clone()); - let mut provider = TestCatalystProvider::default(); - provider.add_document(None, &doc_1).unwrap(); - (doc_id, authors, provider) - } - - #[test_case( - || { - let (doc_id, authors, provider) = gen_original_doc_and_provider(); - let doc_2 = gen_next_ver_doc(doc_id, authors); - (doc_2, provider) - } => true ; - "Catalyst Signed Document has the same authors as the previous version" - )] - #[test_case( - || { - let (doc_id, _, provider) = gen_original_doc_and_provider(); - let other_authors = gen_authors(); - let doc_2 = gen_next_ver_doc(doc_id, other_authors); - (doc_2, provider) - } => false ; - "Catalyst Signed Document has the different authors from the previous version" - )] - #[tokio::test] - async fn original_author_rule_test( - doc_gen: impl FnOnce() -> (CatalystSignedDocument, TestCatalystProvider) - ) -> bool { - let (doc_v2, provider) = doc_gen(); - - OriginalAuthorRule.check(&doc_v2, &provider).await.unwrap() - } -} diff --git a/rust/signed_doc/src/validator/rules/ownership/mod.rs b/rust/signed_doc/src/validator/rules/ownership/mod.rs new file mode 100644 index 0000000000..21e393a0c0 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/ownership/mod.rs @@ -0,0 +1,111 @@ +//! Original Author Validation Rule + +#[cfg(test)] +mod tests; + +use std::collections::HashSet; + +use catalyst_types::catalyst_id::CatalystId; + +use crate::{providers::CatalystSignedDocumentProvider, CatalystSignedDocument}; + +/// Context for the validation problem report. +const REPORT_CONTEXT: &str = "Document ownership validation"; + +/// Returns `true` if the document has a single author. +/// +/// If not, it adds to the document's problem report. +fn single_author_check(doc: &CatalystSignedDocument) -> bool { + let is_valid = doc.authors().len() == 1; + if !is_valid { + doc.report() + .functional_validation("Document must only be signed by one author", REPORT_CONTEXT); + } + is_valid +} + +/// Document Ownership Validation Rule +#[derive(Debug)] +pub(crate) struct DocumentOwnershipRule { + /// Collaborators are allowed. + pub(crate) allow_collaborators: bool, +} + +impl DocumentOwnershipRule { + /// Check document ownership rule + pub(crate) async fn check( + &self, + doc: &CatalystSignedDocument, + provider: &Provider, + ) -> anyhow::Result + where + Provider: CatalystSignedDocumentProvider, + { + let doc_id = doc.doc_id()?; + let first_doc_opt = provider.try_get_first_doc(doc_id).await?; + + if self.allow_collaborators { + if let Some(first_doc) = first_doc_opt { + // This a new version of an existing `doc_id` + let Some(last_doc) = provider.try_get_last_doc(doc_id).await? else { + anyhow::bail!( + "A latest version of the document must exist if a first version exists" + ); + }; + + // Create sets of authors for comparison, ensure that they are in the same form + // (e.g. each `kid` is in `URI form`). + // + // Allowed authors for this document are the original author, and collaborators + // defined in the last published version of the Document ID. + let mut allowed_authors = first_doc + .authors() + .into_iter() + .map(CatalystId::as_uri) + .collect::>(); + allowed_authors.extend( + last_doc + .doc_meta() + .collaborators() + .iter() + .cloned() + .map(CatalystId::as_uri), + ); + let doc_authors = doc + .authors() + .into_iter() + .map(CatalystId::as_uri) + .collect::>(); + + let is_valid = allowed_authors.intersection(&doc_authors).count() > 0; + + if !is_valid { + doc.report().functional_validation( + "Document must only be signed by original author and/or by collaborators defined in the previous version", + REPORT_CONTEXT, + ); + } + return Ok(is_valid); + } + + // This is a first version of the doc + return Ok(single_author_check(doc)); + } + + // No collaborators are allowed + if let Some(first_doc) = first_doc_opt { + // This a new version of an existing `doc_id` + let is_valid = first_doc.authors() == doc.authors(); + if !is_valid { + doc.report().functional_validation( + "Document authors must match the author from the first version", + REPORT_CONTEXT, + ); + } + return Ok(is_valid); + } + + // This is a first version of the doc + Ok(single_author_check(doc)) + } +} diff --git a/rust/signed_doc/src/validator/rules/ownership/tests.rs b/rust/signed_doc/src/validator/rules/ownership/tests.rs new file mode 100644 index 0000000000..1c0ffc0c0c --- /dev/null +++ b/rust/signed_doc/src/validator/rules/ownership/tests.rs @@ -0,0 +1,198 @@ +//! Ownership Validation Rule testing + +use catalyst_types::{ + catalyst_id::{role_index::RoleId, CatalystId}, + uuid::{UuidV4, UuidV7}, +}; +use ed25519_dalek::ed25519::signature::Signer; +use rand::{thread_rng, Rng}; +use test_case::test_case; + +use super::*; +use crate::{ + builder::tests::Builder, metadata::SupportedField, providers::tests::TestCatalystProvider, + validator::rules::utils::create_dummy_key_pair, ContentType, +}; + +const NO_AUTHOR: usize = 0; +const ONE_AUTHOR: usize = 1; + +const NO_COLLABORATORS: usize = 0; +const THREE_COLLABORATORS: usize = 3; + +#[derive(Clone)] +struct CatalystAuthorId { + sk: ed25519_dalek::SigningKey, + kid: CatalystId, +} + +type CatDoc = CatalystSignedDocument; + +type DocId = UuidV7; + +type Authors = Vec; + +type Collaborators = Vec; + +impl CatalystAuthorId { + fn new() -> Self { + let (sk, _, kid) = create_dummy_key_pair(RoleId::Role0); + Self { sk, kid } + } +} + +fn doc_builder( + doc_id: UuidV7, + doc_ver: UuidV7, + authors: Authors, + collaborators: Collaborators, +) -> (UuidV7, Authors, CatalystSignedDocument) { + let mut doc_builder = Builder::new() + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(doc_ver)) + .with_metadata_field(SupportedField::Type(UuidV4::new().into())) + .with_metadata_field(SupportedField::ContentType(ContentType::Json)); + + if !collaborators.is_empty() { + let collaborators = collaborators + .into_iter() + .map(|c| c.kid) + .collect::>(); + doc_builder = + doc_builder.with_metadata_field(SupportedField::Collaborators(collaborators.into())); + } + + for author in &authors { + doc_builder = doc_builder + .add_signature(|m| author.sk.sign(&m).to_vec(), author.kid.clone()) + .unwrap(); + } + (doc_id, authors, doc_builder.build()) +} + +fn gen_authors(size_of: usize) -> Authors { + (0..size_of).map(|_| CatalystAuthorId::new()).collect() +} + +fn gen_next_ver_doc( + doc_id: UuidV7, + authors: Authors, + collaborators: Collaborators, +) -> CatalystSignedDocument { + let (_, _, new_doc) = doc_builder(doc_id, UuidV7::new(), authors, collaborators); + new_doc +} + +fn gen_original_doc_and_provider( + num_authors: usize, + num_collaborators: usize, +) -> (CatDoc, DocId, Authors, Collaborators) { + let authors = gen_authors(num_authors); + let collaborators = gen_authors(num_collaborators); + let doc_id = UuidV7::new(); + let doc_ver_1 = UuidV7::new(); + let (_, _, doc_1) = doc_builder(doc_id, doc_ver_1, authors.clone(), collaborators.clone()); + (doc_1, doc_id, authors, collaborators) +} + +#[test_case( + |_provider| { + let (doc_1, _, _, _) = gen_original_doc_and_provider(ONE_AUTHOR,NO_COLLABORATORS); + doc_1 + } => true ; + "First Version Catalyst Signed Document has only one author" +)] +#[test_case( + |provider| { + let (doc_1, doc_id, authors, _) = gen_original_doc_and_provider(ONE_AUTHOR,NO_COLLABORATORS); + provider.add_document(None, &doc_1).unwrap(); + gen_next_ver_doc(doc_id, authors, Vec::new()) + } => true ; + "Latest Version Catalyst Signed Document has the same author as the first version" +)] +#[test_case( + |_provider| { + let (doc_1, _doc_id, _authors, _) = gen_original_doc_and_provider(NO_AUTHOR,NO_COLLABORATORS); + doc_1 + } => false ; + "First Version Unsigned Catalyst Document fails" +)] +#[test_case( + |provider| { + let (doc_1, doc_id, _authors, _) = gen_original_doc_and_provider(ONE_AUTHOR,NO_COLLABORATORS); + provider.add_document(None, &doc_1).unwrap(); + let other_author = gen_authors(ONE_AUTHOR); + gen_next_ver_doc(doc_id, other_author, Vec::new()) + } => false ; + "Latest Catalyst Signed Document has a different author from the first version" +)] +#[tokio::test] +async fn simple_author_rule_test( + test_case_fn: impl FnOnce(&mut TestCatalystProvider) -> CatalystSignedDocument +) -> bool { + let rule = DocumentOwnershipRule { + allow_collaborators: false, + }; + + let mut provider = TestCatalystProvider::default(); + let doc = test_case_fn(&mut provider); + + rule.check(&doc, &provider).await.unwrap() +} + +#[test_case( + |_provider| { + let (doc_1, _, _, _) = gen_original_doc_and_provider(ONE_AUTHOR,NO_COLLABORATORS); + doc_1 + } => true ; + "First Version Catalyst Signed Document has the only one author" +)] +#[test_case( + |provider| { + let (doc_1, doc_id, mut authors, collaborators) = gen_original_doc_and_provider(ONE_AUTHOR,THREE_COLLABORATORS); + provider.add_document(None, &doc_1).unwrap(); + authors.extend_from_slice(&collaborators); + gen_next_ver_doc(doc_id, authors, Vec::new()) + } => true ; + "Latest Version Catalyst Signed Document signed by first author and all collaborators" +)] +#[allow(clippy::indexing_slicing)] +#[test_case( + |provider| { + let (doc_1, doc_id, _, collaborators) = gen_original_doc_and_provider(ONE_AUTHOR,THREE_COLLABORATORS); + provider.add_document(None, &doc_1).unwrap(); + + let random_collaborator = collaborators[thread_rng().gen_range(0..THREE_COLLABORATORS)].clone(); + gen_next_ver_doc(doc_id, vec![random_collaborator], Vec::new()) + } => true ; + "Latest Version Catalyst Signed Document signed by collaborator" +)] +#[test_case( + |_provider| { + let (doc_1, _doc_id, _authors, _) = gen_original_doc_and_provider(NO_AUTHOR,NO_COLLABORATORS); + doc_1 + } => false ; + "First Version Unsigned Catalyst Document fails" +)] +#[test_case( + |provider| { + let (doc_1, doc_id, _authors, collaborators) = gen_original_doc_and_provider(ONE_AUTHOR,THREE_COLLABORATORS); + provider.add_document(None, &doc_1).unwrap(); + let other_authors = gen_authors(ONE_AUTHOR); + gen_next_ver_doc(doc_id, other_authors, collaborators) + } => false ; + "Latest Catalyst Signed Document signed by unexpected author" +)] +#[tokio::test] +async fn author_with_collaborators_rule_test( + test_case_fn: impl FnOnce(&mut TestCatalystProvider) -> CatalystSignedDocument +) -> bool { + let rule = DocumentOwnershipRule { + allow_collaborators: true, + }; + + let mut provider = TestCatalystProvider::default(); + let doc = test_case_fn(&mut provider); + + rule.check(&doc, &provider).await.unwrap() +}