diff --git a/catalyst-gateway/bin/src/db/event/signed_docs/signed_doc_body.rs b/catalyst-gateway/bin/src/db/event/signed_docs/signed_doc_body.rs index 2d5547545f5c..64f4601b73e8 100644 --- a/catalyst-gateway/bin/src/db/event/signed_docs/signed_doc_body.rs +++ b/catalyst-gateway/bin/src/db/event/signed_docs/signed_doc_body.rs @@ -51,6 +51,11 @@ impl SignedDocBody { &self.doc_type } + /// Returns the document authors. + pub(crate) fn authors(&self) -> &Vec { + &self.authors + } + /// Returns the document metadata. pub(crate) fn metadata(&self) -> Option<&serde_json::Value> { self.metadata.as_ref() diff --git a/catalyst-gateway/bin/src/db/index/queries/rbac/get_rbac_registrations.rs b/catalyst-gateway/bin/src/db/index/queries/rbac/get_rbac_registrations.rs index 92ac8f4e1e0f..61869d54efe9 100644 --- a/catalyst-gateway/bin/src/db/index/queries/rbac/get_rbac_registrations.rs +++ b/catalyst-gateway/bin/src/db/index/queries/rbac/get_rbac_registrations.rs @@ -2,11 +2,17 @@ use std::sync::Arc; +use anyhow::Context; +use cardano_blockchain_types::{Network, Point, Slot, TxnIndex}; +use cardano_chain_follower::ChainFollower; +use catalyst_signed_doc::IdUri; +use futures::{TryFutureExt, TryStreamExt}; +use rbac_registration::{cardano::cip509::Cip509, registration::cardano::RegistrationChain}; use scylla::{ prepared_statement::PreparedStatement, statement::Consistency, transport::iterator::TypedRowStream, DeserializeRow, SerializeRow, Session, }; -use tracing::error; +use tracing::{error, warn}; use crate::db::{ index::{ @@ -63,3 +69,78 @@ impl Query { .map_err(Into::into) } } + +/// Returns a sorted list of all registrations for the given Catalyst ID from the +/// database. +async fn indexed_registrations( + session: &CassandraSession, catalyst_id: &IdUri, +) -> anyhow::Result> { + let mut result: Vec<_> = Query::execute(session, QueryParams { + catalyst_id: catalyst_id.clone().into(), + }) + .and_then(|r| r.try_collect().map_err(Into::into)) + .await?; + + result.sort_by_key(|r| r.slot_no); + Ok(result) +} + +/// Build a registration chain from the given indexed data. +pub(crate) async fn build_reg_chain( + session: &CassandraSession, catalyst_id: &IdUri, network: Network, +) -> anyhow::Result> { + let regs = indexed_registrations(session, catalyst_id).await?; + let mut regs_iter = regs.iter(); + let Some(root) = regs_iter.next() else { + return Ok(None); + }; + + let root = registration(network, root.slot_no.into(), root.txn_index.into()) + .await + .context("Failed to get root registration")?; + let mut chain = RegistrationChain::new(root).context("Invalid root registration")?; + + for reg in regs_iter { + // We only store valid registrations in this table, so an error here indicates a bug in + // our indexing logic. + let cip509 = registration(network, reg.slot_no.into(), reg.txn_index.into()) + .await + .with_context(|| { + format!( + "Invalid or missing registration at {:?} block {:?} transaction", + reg.slot_no, reg.txn_index, + ) + })?; + match chain.update(cip509) { + Ok(c) => chain = c, + Err(e) => { + // This isn't a hard error because while the individual registration can be valid it + // can be invalid in the context of the whole registration chain. + warn!( + "Unable to apply registration from {:?} block {:?} txn index: {e:?}", + reg.slot_no, reg.txn_index + ); + }, + } + } + + Ok(Some(chain)) +} + +/// A helper function to return a RBAC registration from the given block and slot. +async fn registration(network: Network, slot: Slot, txn_index: TxnIndex) -> anyhow::Result { + let point = Point::fuzzy(slot); + let block = ChainFollower::get_block(network, point) + .await + .context("Unable to get block")? + .data; + if block.point().slot_or_default() != slot { + // The `ChainFollower::get_block` function can return the next consecutive block if + // it cannot find the exact one. This shouldn't happen, but we need + // to check anyway. + return Err(anyhow::anyhow!("Unable to find exact block")); + } + Cip509::new(&block, txn_index, &[]) + .context("Invalid RBAC registration")? + .context("No RBAC registration at this block and txn index") +} diff --git a/catalyst-gateway/bin/src/db/index/session.rs b/catalyst-gateway/bin/src/db/index/session.rs index 57f952ca6000..81bad3aa1332 100644 --- a/catalyst-gateway/bin/src/db/index/session.rs +++ b/catalyst-gateway/bin/src/db/index/session.rs @@ -80,6 +80,10 @@ pub(crate) enum CassandraSessionError { /// Error indicating that the session has already been set. #[error("Session already set")] SessionAlreadySet, + /// Should be used by the caller when it fails to acquire the initialized database + /// session. + #[error("Failed acquiring database session")] + FailedAcquiringSession, } /// All interaction with cassandra goes through this struct. diff --git a/catalyst-gateway/bin/src/service/api/cardano/rbac/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/rbac/mod.rs index b45a1aa2b1f8..230968df1709 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/rbac/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/rbac/mod.rs @@ -30,7 +30,7 @@ impl Api { /// No Authorization required, but Token permitted. auth: NoneOrRBAC, ) -> registrations_get::AllResponses { - let auth_catalyst_id = auth.into(); - registrations_get::endpoint(lookup, auth_catalyst_id).await + let token = auth.into(); + registrations_get::endpoint(lookup, token).await } } diff --git a/catalyst-gateway/bin/src/service/api/cardano/rbac/registrations_get/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/rbac/registrations_get/mod.rs index ace40ecd5e51..179ebf5f59a6 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/rbac/registrations_get/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/rbac/registrations_get/mod.rs @@ -34,15 +34,19 @@ use crate::{ chain_info::ChainInfo, registration_chain::RbacRegistrationChain, response::Responses, unprocessable_content::RbacUnprocessableContent, }, - common::types::{ - cardano::query::cat_id_or_stake::CatIdOrStake, headers::retry_after::RetryAfterOption, + common::{ + auth::rbac::token::CatalystRBACTokenV1, + types::{ + cardano::query::cat_id_or_stake::CatIdOrStake, + headers::retry_after::RetryAfterOption, + }, }, }, }; /// Get RBAC registration endpoint. pub(crate) async fn endpoint( - lookup: Option, auth_catalyst_id: Option, + lookup: Option, token: Option, ) -> AllResponses { let Some(persistent_session) = CassandraSession::get(true) else { let err = anyhow!("Failed to acquire persistent db session"); @@ -78,8 +82,8 @@ pub(crate) async fn endpoint( } }, None => { - match auth_catalyst_id { - Some(id) => id, + match token { + Some(token) => token.catalyst_id().clone(), None => { return Responses::UnprocessableContent(Json(RbacUnprocessableContent::new( "Either lookup parameter or token must be provided", diff --git a/catalyst-gateway/bin/src/service/api/documents/common/mod.rs b/catalyst-gateway/bin/src/service/api/documents/common/mod.rs new file mode 100644 index 000000000000..a6429acb5780 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/documents/common/mod.rs @@ -0,0 +1,143 @@ +//! A module for placing common structs, functions, and variables across the `document` +//! endpoint module not specified to a specific endpoint. + +use std::collections::HashMap; + +use catalyst_signed_doc::CatalystSignedDocument; +use rbac_registration::cardano::cip509::RoleNumber; + +use super::templates::get_doc_static_template; +use crate::{ + db::event::{error::NotFoundError, signed_docs::FullSignedDoc}, + service::common::auth::rbac::token::CatalystRBACTokenV1, + settings::Settings, +}; + +/// Get document from the database +pub(crate) async fn get_document( + document_id: &uuid::Uuid, version: Option<&uuid::Uuid>, +) -> anyhow::Result { + // Find the doc in the static templates first + if let Some(doc) = get_doc_static_template(document_id) { + return Ok(doc); + } + + // If doesn't exist in the static templates, try to find it in the database + let db_doc = FullSignedDoc::retrieve(document_id, version).await?; + db_doc.raw().try_into() +} + +/// A struct which implements a +/// `catalyst_signed_doc::providers::CatalystSignedDocumentProvider` trait +pub(crate) struct DocProvider; + +impl catalyst_signed_doc::providers::CatalystSignedDocumentProvider for DocProvider { + async fn try_get_doc( + &self, doc_ref: &catalyst_signed_doc::DocumentRef, + ) -> anyhow::Result> { + let id = doc_ref.id.uuid(); + let ver = doc_ref.ver.uuid(); + match get_document(&id, Some(&ver)).await { + Ok(doc) => Ok(Some(doc)), + Err(err) if err.is::() => Ok(None), + Err(err) => Err(err), + } + } + + fn future_threshold(&self) -> Option { + let signed_doc_cfg = Settings::signed_doc_cfg(); + Some(signed_doc_cfg.future_threshold()) + } + + fn past_threshold(&self) -> Option { + let signed_doc_cfg = Settings::signed_doc_cfg(); + Some(signed_doc_cfg.past_threshold()) + } +} + +// TODO: make the struct to support multi sigs validation +/// A struct which implements a +/// `catalyst_signed_doc::providers::CatalystSignedDocumentProvider` trait +pub(crate) struct VerifyingKeyProvider( + HashMap, +); + +impl catalyst_signed_doc::providers::VerifyingKeyProvider for VerifyingKeyProvider { + async fn try_get_key( + &self, kid: &catalyst_signed_doc::IdUri, + ) -> anyhow::Result> { + Ok(self.0.get(kid).copied()) + } +} + +impl VerifyingKeyProvider { + /// Attempts to construct an instance of `Self` by validating and resolving a list of + /// Catalyst Document KIDs against a provided RBAC token. + /// + /// This method performs the following steps: + /// 1. Verifies that only a single KID is provided with a document (as multi-signature + /// is currently unsupported). + /// 2. Verifies that **all** provided KIDs match the Catalyst ID from the RBAC token. + /// 3. Verifies that each provided KID is actually a signing key. + /// 4. Extracts the role index and rotation from each KID. + /// 5. Retrieves the latest signing public key and rotation state associated with the + /// role for each KID from the registration chain. + /// 6. Verifies that each provided KID uses its latest rotation. + /// 7. Collects and returns a vector of tuples containing the KID, and its latest + /// signing key. + /// + /// # Errors + /// + /// Returns an `anyhow::Error` if: + /// - Any KID's short Catalyst ID does not match the one in the token. + /// - Indexed registration queries or chain building fail. + /// - The KID's role index and rotation parsing fails. + /// - The KID is not a singing key. + /// - The latest signing key for a required role cannot be found. + /// - The KID is not using the latest rotation. + pub(crate) async fn try_from_kids( + token: &mut CatalystRBACTokenV1, kids: &[catalyst_signed_doc::IdUri], + ) -> anyhow::Result { + if kids.len() > 1 { + anyhow::bail!("Multi-signature document is currently unsupported"); + } + + if kids + .iter() + .any(|kid| kid.as_short_id() != token.catalyst_id().as_short_id()) + { + anyhow::bail!("RBAC Token CatID does not match with the document KIDs"); + } + + let Some(reg_chain) = token.reg_chain().await? else { + anyhow::bail!("Failed to retrieve a registration from corresponding Catalyst ID"); + }; + + let result = kids.iter().map(|kid| { + if !kid.is_signature_key() { + anyhow::bail!("Invalid KID {kid}: KID must be a signing key not an encryption key"); + } + + let (kid_role_index, kid_rotation) = kid.role_and_rotation(); + let kid_role_index = RoleNumber::from(kid_role_index.to_string().parse::()?); + let kid_rotation = kid_rotation.to_string().parse::()?; + + let (latest_pk, rotation) = reg_chain + .get_latest_signing_pk_for_role(&kid_role_index) + .ok_or_else(|| { + anyhow::anyhow!( + "Failed to get last signing key for the proposer role for {kid} Catalyst ID" + ) + })?; + + if rotation != kid_rotation { + anyhow::bail!("Invalid KID {kid}: KID's rotation ({kid_rotation}) is not the latest rotation ({rotation})"); + } + + Ok((kid.clone(), latest_pk)) + }) + .collect::>()?; + + Ok(Self(result)) + } +} diff --git a/catalyst-gateway/bin/src/service/api/documents/get_document.rs b/catalyst-gateway/bin/src/service/api/documents/get_document.rs index b433de261f36..de7e8cff92d2 100644 --- a/catalyst-gateway/bin/src/service/api/documents/get_document.rs +++ b/catalyst-gateway/bin/src/service/api/documents/get_document.rs @@ -1,13 +1,11 @@ //! Implementation of the GET `/document` endpoint -use catalyst_signed_doc::CatalystSignedDocument; use poem_openapi::ApiResponse; -use super::templates::get_doc_static_template; +use super::common; use crate::{ - db::event::{error::NotFoundError, signed_docs::FullSignedDoc}, + db::event::error::NotFoundError, service::common::{responses::WithErrorResponses, types::payload::cbor::Cbor}, - settings::Settings, }; /// Endpoint responses. @@ -30,7 +28,7 @@ pub(crate) type AllResponses = WithErrorResponses; /// # GET `/document` pub(crate) async fn endpoint(document_id: uuid::Uuid, version: Option) -> AllResponses { - match get_document(&document_id, version.as_ref()).await { + match common::get_document(&document_id, version.as_ref()).await { Ok(doc) => { match doc.try_into() { Ok(doc_cbor_bytes) => Responses::Ok(Cbor(doc_cbor_bytes)).into(), @@ -41,45 +39,3 @@ pub(crate) async fn endpoint(document_id: uuid::Uuid, version: Option AllResponses::handle_error(&err), } } - -/// Get document from the database -pub(crate) async fn get_document( - document_id: &uuid::Uuid, version: Option<&uuid::Uuid>, -) -> anyhow::Result { - // Find the doc in the static templates first - if let Some(doc) = get_doc_static_template(document_id) { - return Ok(doc); - } - - // If doesn't exist in the static templates, try to find it in the database - let db_doc = FullSignedDoc::retrieve(document_id, version).await?; - db_doc.raw().try_into() -} - -/// A struct which implements a -/// `catalyst_signed_doc::providers::CatalystSignedDocumentProvider` trait -pub(crate) struct DocProvider; - -impl catalyst_signed_doc::providers::CatalystSignedDocumentProvider for DocProvider { - async fn try_get_doc( - &self, doc_ref: &catalyst_signed_doc::DocumentRef, - ) -> anyhow::Result> { - let id = doc_ref.id.uuid(); - let ver = doc_ref.ver.uuid(); - match get_document(&id, Some(&ver)).await { - Ok(doc) => Ok(Some(doc)), - Err(err) if err.is::() => Ok(None), - Err(err) => Err(err), - } - } - - fn future_threshold(&self) -> Option { - let signed_doc_cfg = Settings::signed_doc_cfg(); - Some(signed_doc_cfg.future_threshold()) - } - - fn past_threshold(&self) -> Option { - let signed_doc_cfg = Settings::signed_doc_cfg(); - Some(signed_doc_cfg.past_threshold()) - } -} diff --git a/catalyst-gateway/bin/src/service/api/documents/mod.rs b/catalyst-gateway/bin/src/service/api/documents/mod.rs index 0719602b975f..a65546a0d50e 100644 --- a/catalyst-gateway/bin/src/service/api/documents/mod.rs +++ b/catalyst-gateway/bin/src/service/api/documents/mod.rs @@ -27,6 +27,7 @@ use crate::service::{ utilities::middleware::schema_validation::schema_version_validation, }; +mod common; mod get_document; mod post_document_index_query; mod put_document; @@ -81,10 +82,10 @@ impl DocumentApi { &self, /// The document to PUT document: Cbor, /// Authorization required. - _auth: CatalystRBACSecurityScheme, + auth: CatalystRBACSecurityScheme, ) -> put_document::AllResponses { match document.0.into_bytes_limit(MAXIMUM_DOCUMENT_SIZE).await { - Ok(doc_bytes) => put_document::endpoint(doc_bytes.to_vec()).await, + Ok(doc_bytes) => put_document::endpoint(doc_bytes.to_vec(), auth.into()).await, Err(ReadBodyError::PayloadTooLarge) => put_document::Responses::PayloadTooLarge.into(), Err(_) => { put_document::Responses::UnprocessableContent(Json( diff --git a/catalyst-gateway/bin/src/service/api/documents/put_document/mod.rs b/catalyst-gateway/bin/src/service/api/documents/put_document/mod.rs index b0894f4b3aad..878449be8f8a 100644 --- a/catalyst-gateway/bin/src/service/api/documents/put_document/mod.rs +++ b/catalyst-gateway/bin/src/service/api/documents/put_document/mod.rs @@ -1,13 +1,24 @@ //! Implementation of the PUT `/document` endpoint -use anyhow::anyhow; +use std::{collections::HashSet, str::FromStr}; + +use catalyst_signed_doc::CatalystSignedDocument; use poem_openapi::{payload::Json, ApiResponse}; use unprocessable_content_request::PutDocumentUnprocessableContent; -use super::get_document::DocProvider; +use super::common::{DocProvider, VerifyingKeyProvider}; use crate::{ - db::event::signed_docs::{FullSignedDoc, SignedDocBody, StoreError}, - service::common::responses::WithErrorResponses, + db::{ + event::{ + error, + signed_docs::{FullSignedDoc, SignedDocBody, StoreError}, + }, + index::session::CassandraSessionError, + }, + service::common::{ + auth::rbac::token::CatalystRBACTokenV1, responses::WithErrorResponses, + types::headers::retry_after::RetryAfterOption, + }, }; pub(crate) mod unprocessable_content_request; @@ -45,53 +56,120 @@ pub(crate) enum Responses { pub(crate) type AllResponses = WithErrorResponses; /// # PUT `/document` -pub(crate) async fn endpoint(doc_bytes: Vec) -> AllResponses { - match doc_bytes.as_slice().try_into() { - Ok(doc) => { - if let Err(e) = catalyst_signed_doc::validator::validate(&doc, &DocProvider).await { - // means that something happened inside the `DocProvider`, some db error. - return AllResponses::handle_error(&e); - } - - let report = doc.problem_report(); - if report.is_problematic() { - return return_error_report(&report); - } - - match store_document_in_db(&doc, doc_bytes).await { - Ok(true) => Responses::Created.into(), - Ok(false) => Responses::NoContent.into(), - Err(err) if err.is::() => { - Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( - "Document with the same `id` and `ver` already exists", - None, - ))) - .into() - }, - Err(err) => AllResponses::handle_error(&err), - } +pub(crate) async fn endpoint(doc_bytes: Vec, mut token: CatalystRBACTokenV1) -> AllResponses { + let Ok(doc): Result = + doc_bytes.as_slice().try_into() + else { + return Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( + "Invalid CBOR bytes, cannot decode Catalyst Signed Document", + None, + ))) + .into(); + }; + + // validate document integrity + match catalyst_signed_doc::validator::validate(&doc, &DocProvider).await { + Ok(true) => (), + Ok(false) => { + return Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( + "Failed validating document integrity", + serde_json::to_value(doc.problem_report()).ok(), + ))) + .into(); }, - Err(_) => { + Err(err) => { + // means that something happened inside the `DocProvider`, some db error. + return AllResponses::handle_error(&err); + }, + } + + // validate document signatures + let verifying_key_provider = + match VerifyingKeyProvider::try_from_kids(&mut token, &doc.kids()).await { + Ok(value) => value, + Err(err) if err.is::() => { + return AllResponses::service_unavailable(&err, RetryAfterOption::Default) + }, + Err(err) => { + return Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( + &err, None, + ))) + .into() + }, + }; + match catalyst_signed_doc::validator::validate_signatures(&doc, &verifying_key_provider).await { + Ok(true) => (), + Ok(false) => { + return Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( + "Failed validating document signatures", + serde_json::to_value(doc.problem_report()).ok(), + ))) + .into(); + }, + Err(err) => { + return AllResponses::handle_error(&err); + }, + }; + + if doc.problem_report().is_problematic() { + return Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( + "Invalid Catalyst Signed Document", + serde_json::to_value(doc.problem_report()).ok(), + ))) + .into(); + } + + match validate_against_original_doc(&doc).await { + Ok(true) => (), + Ok(false) => { + return Responses::UnprocessableContent(Json( + PutDocumentUnprocessableContent::new("Failed validating document: catalyst-id or role does not match the current version.", None), + )) + .into(); + }, + Err(err) => return AllResponses::handle_error(&err), + }; + + // update the document storing in the db + match store_document_in_db(&doc, doc_bytes).await { + Ok(true) => Responses::Created.into(), + Ok(false) => Responses::NoContent.into(), + Err(err) if err.is::() => { Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( - "Invalid CBOR bytes, cannot decode Catalyst Signed Document.", - None, + "Document with the same `id` and `ver` already exists", + serde_json::to_value(doc.problem_report()).ok(), ))) .into() }, + Err(err) => AllResponses::handle_error(&err), } } +/// Fetch the latest version and ensure its catalyst-id match those in the newer version. +async fn validate_against_original_doc(doc: &CatalystSignedDocument) -> anyhow::Result { + let original_doc = match FullSignedDoc::retrieve(&doc.doc_id()?.uuid(), None).await { + Ok(doc) => doc, + Err(e) if e.is::() => return Ok(true), + Err(e) => anyhow::bail!("Database error: {e}"), + }; + + let original_authors = original_doc + .body() + .authors() + .iter() + .map(|author| catalyst_signed_doc::IdUri::from_str(author)) + .collect::, _>>()?; + let authors: HashSet<_> = doc.authors().into_iter().collect(); + Ok(authors == original_authors) +} + /// Store a provided and validated document inside the db. /// Returns `true` if its a new document. /// Returns `false` if the same document already exists. async fn store_document_in_db( doc: &catalyst_signed_doc::CatalystSignedDocument, doc_bytes: Vec, ) -> anyhow::Result { - let authors = doc - .authors() - .into_iter() - .map(|kid| kid.to_string()) - .collect(); + let authors = doc.authors().iter().map(ToString::to_string).collect(); let doc_meta_json = match serde_json::to_value(doc.doc_meta()) { Ok(json) => json, @@ -126,20 +204,3 @@ async fn store_document_in_db( .store() .await } - -/// Return a response with the full error report from `CatalystSignedDocError` -fn return_error_report(report: &catalyst_signed_doc::ProblemReport) -> AllResponses { - let json_report = match serde_json::to_value(report) { - Ok(json_report) => json_report, - Err(e) => { - return AllResponses::internal_error(&anyhow!( - "Invalid Signed Document Problem Report, not Json encoded: {e}" - )) - }, - }; - Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( - "Invalid Catalyst Signed Document", - Some(json_report), - ))) - .into() -} diff --git a/catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs b/catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs index 10d5234eb3b1..16117411c833 100644 --- a/catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs +++ b/catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs @@ -1,13 +1,15 @@ //! Either has No Authorization, or RBAC Token. -use catalyst_types::id_uri::IdUri; use poem::{ web::headers::{authorization::Bearer, Authorization, HeaderMapExt}, Request, RequestBody, }; use poem_openapi::{registry::Registry, ApiExtractor, ApiExtractorType, ExtractParamOptions}; -use super::{none::NoAuthorization, rbac::scheme::CatalystRBACSecurityScheme}; +use super::{ + none::NoAuthorization, + rbac::{scheme::CatalystRBACSecurityScheme, token::CatalystRBACTokenV1}, +}; #[allow(dead_code, clippy::upper_case_acronyms, clippy::large_enum_variant)] /// Endpoint allows Authorization with or without RBAC Token. @@ -48,7 +50,7 @@ impl<'a> ApiExtractor<'a> for NoneOrRBAC { } } -impl From for Option { +impl From for Option { fn from(value: NoneOrRBAC) -> Self { match value { NoneOrRBAC::RBAC(auth) => Some(auth.into()), diff --git a/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs index 8f08569515f4..95435275940e 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs @@ -1,37 +1,22 @@ //! Catalyst RBAC Security Scheme use std::{env, error::Error, sync::LazyLock, time::Duration}; -use anyhow::{anyhow, Context}; -use cardano_blockchain_types::{Network, Point, Slot, TxnIndex}; -use cardano_chain_follower::ChainFollower; -use catalyst_types::id_uri::IdUri; -use ed25519_dalek::VerifyingKey; -use futures::{TryFutureExt, TryStreamExt}; use moka::future::Cache; use poem::{error::ResponseError, http::StatusCode, IntoResponse, Request}; -use poem_openapi::{auth::Bearer, payload::Json, SecurityScheme}; -use rbac_registration::{ - cardano::cip509::{Cip509, RoleNumber}, - registration::cardano::RegistrationChain, -}; -use tracing::{error, warn}; +use poem_openapi::{auth::Bearer, SecurityScheme}; +use rbac_registration::cardano::cip509::RoleNumber; +use tracing::error; use super::token::CatalystRBACTokenV1; use crate::{ - db::index::{ - queries::rbac::get_rbac_registrations::{Query, QueryParams}, - session::CassandraSession, - }, + db::index::session::CassandraSessionError, service::common::{ - responses::{ - code_500_internal_server_error::InternalServerError, - code_503_service_unavailable::ServiceUnavailable, ErrorResponses, - }, - types::headers::retry_after::RetryAfterHeader, + responses::{ErrorResponses, WithErrorResponses}, + types::headers::retry_after::{RetryAfterHeader, RetryAfterOption}, }, }; -/// Auth token in the form of catv1.. +/// Auth token in the form of catv1. pub type EncodedAuthToken = String; /// The header name that holds the authorization RBAC token @@ -60,11 +45,34 @@ static CACHE: LazyLock> = LazyLock: checker = "checker_api_catalyst_auth" )] #[allow(dead_code, clippy::module_name_repetitions)] -pub struct CatalystRBACSecurityScheme(CatalystRBACTokenV1); +pub(crate) struct CatalystRBACSecurityScheme(CatalystRBACTokenV1); -impl From for IdUri { +impl From for CatalystRBACTokenV1 { fn from(value: CatalystRBACSecurityScheme) -> Self { - value.0.into() + value.0 + } +} + +/// Error with the service while processing a Catalyst RBAC Token +/// +/// Can be related to database session failure. +#[derive(Debug, thiserror::Error)] +#[error("Service unavailable while processing a Catalyst RBAC Token")] +pub struct ServiceUnavailableError(pub anyhow::Error); + +impl ResponseError for ServiceUnavailableError { + fn status(&self) -> StatusCode { + StatusCode::SERVICE_UNAVAILABLE + } + + /// Convert this error to a HTTP response. + fn as_response(&self) -> poem::Response + where Self: Error + Send + Sync + 'static { + WithErrorResponses::<()>::service_unavailable( + &self.0, + RetryAfterOption::Some(RetryAfterHeader::default()), + ) + .into_response() } } @@ -124,7 +132,7 @@ async fn checker_api_catalyst_auth( const RBAC_OFF: &str = "RBAC_OFF"; // Deserialize the token: this performs the 1-5 steps of the validation. - let token = CatalystRBACTokenV1::parse(&bearer.token).map_err(|e| { + let mut token = CatalystRBACTokenV1::parse(&bearer.token).map_err(|e| { error!("Corrupt auth token: {e:?}"); AuthTokenError })?; @@ -134,15 +142,24 @@ async fn checker_api_catalyst_auth( return Ok(token); }; - let registrations = indexed_registrations(token.catalyst_id()).await?; - // Step 6: return 401 if the token isn't known. - if registrations.is_empty() { - error!( - "Unable to find registrations for {} Catalyst ID", - token.catalyst_id() - ); - return Err(AuthTokenError.into()); - } + // Step 6: get and build latest registration chain from the db. + let reg_chain = match token.reg_chain().await { + Ok(Some(reg_chain)) => reg_chain, + Ok(None) => { + error!( + "Unable to find registrations for {} Catalyst ID", + token.catalyst_id() + ); + return Err(AuthTokenError.into()); + }, + Err(err) if err.is::() => { + return Err(ServiceUnavailableError(err).into()) + }, + Err(err) => { + error!("Unable to build a registration chain Catalyst ID: {err:?}"); + return Err(AuthTokenError.into()); + }, + }; // Step 7: Verify that the nonce is in the acceptable range. if !token.is_young(MAX_TOKEN_AGE, MAX_TOKEN_SKEW) { @@ -163,20 +180,19 @@ async fn checker_api_catalyst_auth( // return Ok(token); // } - // Step 8: get the latest stable signing certificate registered for Role 0. - - let public_key = last_signing_key(token.network(), ®istrations) - .await - .map_err(|e| { + // Step 8: Get the latest stable signing certificate registered for Role 0. + let (latest_pk, _) = reg_chain + .get_latest_signing_pk_for_role(&RoleNumber::ROLE_0) + .ok_or_else(|| { error!( - "Unable to get last signing key for {} Catalyst ID: {e:?}", + "Unable to get last signing key for {} Catalyst ID", token.catalyst_id() ); AuthTokenError })?; - // Step 9: Verify the signature. - if let Err(error) = token.verify(&public_key) { + // Step 9: Verify the signature against the Role 0 pk. + if let Err(error) = token.verify(&latest_pk) { error!(error=%error, "Invalid signature for token: {token}"); Err(AuthTokenAccessViolation(vec![ "INVALID SIGNATURE".to_string() @@ -193,111 +209,6 @@ async fn checker_api_catalyst_auth( // // This entry will expire after 5 minutes (TTI) if there is no more (). // CACHE.insert(bearer.token, token.clone()).await; + // Step 11: Token is valid Ok(token) } - -/// Returns a sorted list of all registrations for the given Catalyst ID from the -/// database. -async fn indexed_registrations(catalyst_id: &IdUri) -> poem::Result> { - let session = CassandraSession::get(true).ok_or_else(|| { - error!("Failed to acquire db session"); - service_unavailable() - })?; - - let mut result: Vec<_> = Query::execute(&session, QueryParams { - catalyst_id: catalyst_id.clone().into(), - }) - .and_then(|r| r.try_collect().map_err(Into::into)) - .await - .map_err(|e| { - error!("Failed to get RBAC registrations for {catalyst_id} Catalyst ID: {e:?}"); - if e.is::>() { - service_unavailable() - } else { - let error = InternalServerError::new(None); - error!(id=%error.id(), error=?e); - ErrorResponses::ServerError(Json(error)).into() - } - })?; - - result.sort_by_key(|r| r.slot_no); - Ok(result) -} - -/// Returns a 503 error instance. -fn service_unavailable() -> poem::Error { - let error = ServiceUnavailable::new(None); - ErrorResponses::ServiceUnavailable(Json(error), Some(RetryAfterHeader::default())).into() -} - -/// Returns the last signing key from the registration chain. -async fn last_signing_key( - network: Network, indexed_registrations: &[Query], -) -> anyhow::Result { - let chain = registration_chain(network, indexed_registrations) - .await - .context("Failed to build registration chain")?; - chain - .get_latest_signing_pk_for_role(&RoleNumber::ROLE_0) - .ok_or(anyhow!("Cannot find latest role 0 public key")) - .map(|(pk, _)| pk) -} - -/// Build a registration chain from the given indexed data. -async fn registration_chain( - network: Network, indexed_registrations: &[Query], -) -> anyhow::Result { - let mut indexed_registrations = indexed_registrations.iter(); - let Some(root) = indexed_registrations.next() else { - // We already checked that the registrations aren't empty, so we shouldn't get there. - return Err(anyhow!("Empty registrations list")); - }; - - let root = registration(network, root.slot_no.into(), root.txn_index.into()) - .await - .context("Failed to get root registration")?; - let mut chain = RegistrationChain::new(root).context("Invalid root registration")?; - - for reg in indexed_registrations { - // We only store valid registrations in this table, so an error here indicates a bug in - // our indexing logic. - let cip509 = registration(network, reg.slot_no.into(), reg.txn_index.into()) - .await - .with_context(|| { - format!( - "Invalid or missing registration at {:?} block {:?} transaction", - reg.slot_no, reg.txn_index, - ) - })?; - match chain.update(cip509) { - Ok(c) => chain = c, - Err(e) => { - // This isn't a hard error because while the individual registration can be valid it - // can be invalid in the context of the whole registration chain. - warn!( - "Unable to apply registration from {:?} block {:?} txn index: {e:?}", - reg.slot_no, reg.txn_index - ); - }, - } - } - - Ok(chain) -} - -/// Returns a RBAC registration from the given block and slot. -async fn registration(network: Network, slot: Slot, txn_index: TxnIndex) -> anyhow::Result { - let point = Point::fuzzy(slot); - let block = ChainFollower::get_block(network, point) - .await - .context("Unable to get block")? - .data; - if block.point().slot_or_default() != slot { - // The `ChainFollower::get_block` function can return the next consecutive block if it - // cannot find the exact one. This shouldn't happen, but we need to check anyway. - return Err(anyhow!("Unable to find exact block")); - } - Cip509::new(&block, txn_index, &[]) - .context("Invalid RBAC registration")? - .context("No RBAC registration at this block and txn index") -} diff --git a/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs index 1e995e1f8ce6..bdade1d1520d 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs @@ -13,6 +13,12 @@ use cardano_blockchain_types::Network; use catalyst_types::id_uri::{key_rotation::KeyRotation, role_index::RoleIndex, IdUri}; use chrono::{TimeDelta, Utc}; use ed25519_dalek::{ed25519::signature::Signer, Signature, SigningKey, VerifyingKey}; +use rbac_registration::registration::cardano::RegistrationChain; + +use crate::db::index::{ + queries::rbac::get_rbac_registrations::build_reg_chain, + session::{CassandraSession, CassandraSessionError}, +}; /// A Catalyst RBAC Authorization Token. /// @@ -32,6 +38,9 @@ pub(crate) struct CatalystRBACTokenV1 { signature: Signature, /// Raw bytes of the token without the signature. raw: Vec, + /// A corresponded RBAC chain, constructed from the most recent data from the + /// database. Lazy initialized + reg_chain: Option, } impl CatalystRBACTokenV1 { @@ -54,6 +63,7 @@ impl CatalystRBACTokenV1 { network, signature, raw, + reg_chain: None, }) } @@ -109,6 +119,7 @@ impl CatalystRBACTokenV1 { network, signature, raw, + reg_chain: None, }) } @@ -156,11 +167,16 @@ impl CatalystRBACTokenV1 { pub(crate) fn network(&self) -> Network { self.network } -} -impl From for IdUri { - fn from(value: CatalystRBACTokenV1) -> Self { - value.catalyst_id + /// Returns a corresponded registration chain if any registrations present. + /// If it is a first call, fetch all data from the database and initialize it. + pub(crate) async fn reg_chain(&mut self) -> anyhow::Result> { + if self.reg_chain.is_none() { + let session = + CassandraSession::get(true).ok_or(CassandraSessionError::FailedAcquiringSession)?; + self.reg_chain = build_reg_chain(&session, self.catalyst_id(), self.network()).await?; + } + Ok(self.reg_chain.clone()) } } diff --git a/catalyst-gateway/tests/api_tests/Earthfile b/catalyst-gateway/tests/api_tests/Earthfile index 3a6daa7901de..c743934a0b67 100644 --- a/catalyst-gateway/tests/api_tests/Earthfile +++ b/catalyst-gateway/tests/api_tests/Earthfile @@ -1,7 +1,7 @@ VERSION 0.8 IMPORT github.com/input-output-hk/catalyst-ci/earthly/python:v3.3.3 AS python-ci -IMPORT github.com/input-output-hk/catalyst-libs/rust:r20250330-00 AS cat-libs-rust +IMPORT github.com/input-output-hk/catalyst-libs/rust:r20250416-00 AS cat-libs-rust builder: FROM python-ci+python-base diff --git a/catalyst-gateway/tests/api_tests/Readme.md b/catalyst-gateway/tests/api_tests/Readme.md index d315f2242f6c..7bc7225f609c 100644 --- a/catalyst-gateway/tests/api_tests/Readme.md +++ b/catalyst-gateway/tests/api_tests/Readme.md @@ -5,10 +5,42 @@ Sets up a containerized environment with the `EventDB` and `catalyst-gateway` se Integration tests are run in this environment that probe the behavior of the `catalyst-gateway` service in situations where the DB schema version changes during execution, and creates a mismatch with the version that gateway service expects. -## Running +## Running Locally -To run: +* Spin up `scylla-node` and `event-db` databases -```bash -earthly -P +test +```shell +cd .. +docker compose up scylla-node event-db --detach +``` + +* Running a `catalyst gateway` + +```shell +cd ../.. +cargo b --release +export EVENT_DB_URL="postgres://catalyst-event-dev:CHANGE_ME@localhost:5432/CatalystEventDev" +export CHAIN_NETWORK="Preprod" +export SIGNED_DOC_SK="0x6455585b5dcc565c8975bc136e215d6d4dd96540620f37783c564da3cb3686dd" +./target/release/cat-gateway run +``` + +* Also you need to compile a [`mk_singed_doc` cli tool](https://github.com/input-output-hk/catalyst-libs/tree/main/rust/signed_doc) + (version `r20250416-00`), +which is used for building and signing Catalyst Signed Document objects. +And copy this binary under this directory `api_tests`. + +```shell +git clone https://github.com/input-output-hk/catalyst-libs.git +cd catalyst-libs/rust +cargo b --release -p catalyst-signed-doc +cp ./target/release/mk_singed_doc /api_tests +``` + +* Running tests + +```shell +export EVENT_DB_TEST_URL="postgres://catalyst-event-dev:CHANGE_ME@localhost/CatalystEventDev" +export CAT_GATEWAY_TEST_URL="http://127.0.0.1:3030" +poetry run pytest -s -m preprod_indexing ``` diff --git a/catalyst-gateway/tests/api_tests/api/v1/document.py b/catalyst-gateway/tests/api_tests/api/v1/document.py index d7b317b68895..b5f685d0123e 100644 --- a/catalyst-gateway/tests/api_tests/api/v1/document.py +++ b/catalyst-gateway/tests/api_tests/api/v1/document.py @@ -3,27 +3,22 @@ URL = cat_gateway_endpoint_url("api/v1/document") + # Signed document GET def get(document_id: str, token: str): document_url = f"{URL}/{document_id}" - headers = { - "Authorization": f"Bearer {token}" - } + headers = {"Authorization": f"Bearer {token}"} return requests.get(document_url, headers=headers) + # Signed document PUT def put(data: str, token: str): - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/cbor" - } + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/cbor"} data = bytes.fromhex(data) return requests.put(URL, headers=headers, data=data) + # Signed document POST def post(document_url: str, filter: dict, token: str): - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} return requests.post(f"{URL}{document_url}", headers=headers, json=filter) diff --git a/catalyst-gateway/tests/api_tests/integration/test_signed_doc.py b/catalyst-gateway/tests/api_tests/integration/test_signed_doc.py index def0fdf58b56..3447b5f421e2 100644 --- a/catalyst-gateway/tests/api_tests/integration/test_signed_doc.py +++ b/catalyst-gateway/tests/api_tests/integration/test_signed_doc.py @@ -6,7 +6,7 @@ import json from typing import Dict, Any, List import copy -from utils.auth_token import rbac_auth_token_factory +from utils.rbac_chain import rbac_chain_factory, RoleID, RBACChain class SignedDocument: @@ -21,9 +21,15 @@ def copy(self): ) return new_copy - # return hex bytes - def hex(self) -> str: - return signed_doc.build_signed_doc(self.metadata, self.content) + # Build and sign document, returns hex str of document bytes + def build_and_sign( + self, + cat_id: str, + bip32_sk_hex: str, + ) -> str: + return signed_doc.build_signed_doc( + self.metadata, self.content, bip32_sk_hex, cat_id + ) @pytest.fixture @@ -66,9 +72,9 @@ def comment_templates() -> List[str]: # return a Proposal document which is already published to the cat-gateway @pytest.fixture -def proposal_doc_factory(proposal_templates, rbac_auth_token_factory): +def proposal_doc_factory(proposal_templates, rbac_chain_factory): def __proposal_doc_factory() -> SignedDocument: - rbac_auth_token = rbac_auth_token_factory() + rbac_chain = rbac_chain_factory(RoleID.PROPOSER) proposal_doc_id = uuid_v7.uuid_v7() category_id = "0194d490-30bf-7473-81c8-a0eaef369619" proposal_metadata_json = { @@ -93,7 +99,11 @@ def __proposal_doc_factory() -> SignedDocument: proposal_json = json.load(proposal_json_file) doc = SignedDocument(proposal_metadata_json, proposal_json) - resp = document.put(data=doc.hex(), token=rbac_auth_token) + (cat_id, sk_hex) = rbac_chain.cat_id_for_role(RoleID.PROPOSER) + resp = document.put( + data=doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 201 ), f"Failed to publish document: {resp.status_code} - {resp.text}" @@ -106,10 +116,10 @@ def __proposal_doc_factory() -> SignedDocument: # return a Comment document which is already published to the cat-gateway @pytest.fixture def comment_doc_factory( - proposal_doc_factory, comment_templates, rbac_auth_token_factory + proposal_doc_factory, comment_templates, rbac_chain_factory ) -> SignedDocument: def __comment_doc_factory() -> SignedDocument: - rbac_auth_token = rbac_auth_token_factory() + rbac_chain = rbac_chain_factory(RoleID.ROLE_0) proposal_doc = proposal_doc_factory() comment_doc_id = uuid_v7.uuid_v7() comment_metadata_json = { @@ -132,7 +142,11 @@ def __comment_doc_factory() -> SignedDocument: comment_json = json.load(comment_json_file) doc = SignedDocument(comment_metadata_json, comment_json) - resp = document.put(data=doc.hex(), token=rbac_auth_token) + (cat_id, sk_hex) = rbac_chain.cat_id_for_role(RoleID.ROLE_0) + resp = document.put( + data=doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 201 ), f"Failed to publish document: {resp.status_code} - {resp.text}" @@ -145,10 +159,10 @@ def __comment_doc_factory() -> SignedDocument: # return a submission action document. @pytest.fixture def submission_action_factory( - proposal_doc_factory, comment_templates, rbac_auth_token_factory + proposal_doc_factory, comment_templates, rbac_chain_factory ) -> SignedDocument: def __submission_action_factory() -> SignedDocument: - rbac_auth_token = rbac_auth_token_factory() + rbac_chain = rbac_chain_factory(RoleID.PROPOSER) proposal_doc = proposal_doc_factory() submission_action_id = uuid_v7.uuid_v7() sub_action_metadata_json = { @@ -169,7 +183,11 @@ def __submission_action_factory() -> SignedDocument: comment_json = json.load(comment_json_file) doc = SignedDocument(sub_action_metadata_json, comment_json) - resp = document.put(data=doc.hex(), token=rbac_auth_token) + (cat_id, sk_hex) = rbac_chain.cat_id_for_role(RoleID.PROPOSER) + resp = document.put( + data=doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 201 ), f"Failed to publish sub_action: {resp.status_code} - {resp.text}" @@ -179,45 +197,64 @@ def __submission_action_factory() -> SignedDocument: return __submission_action_factory -def test_templates(proposal_templates, comment_templates, rbac_auth_token_factory): - rbac_auth_token = rbac_auth_token_factory() +def test_templates(proposal_templates, comment_templates, rbac_chain_factory): + rbac_chain = rbac_chain_factory(RoleID.ROLE_0) templates = proposal_templates + comment_templates for template_id in templates: - resp = document.get(document_id=template_id, token=rbac_auth_token) + resp = document.get(document_id=template_id, token=rbac_chain.auth_token()) assert ( resp.status_code == 200 ), f"Failed to get document: {resp.status_code} - {resp.text} for id {template_id}" -def test_proposal_doc(proposal_doc_factory, rbac_auth_token_factory): - rbac_auth_token = rbac_auth_token_factory() +@pytest.mark.preprod_indexing +def test_proposal_doc(proposal_doc_factory, rbac_chain_factory): + rbac_chain = rbac_chain_factory(RoleID.PROPOSER) + (cat_id, sk_hex) = rbac_chain.cat_id_for_role(RoleID.PROPOSER) proposal_doc = proposal_doc_factory() proposal_doc_id = proposal_doc.metadata["id"] - # Put a proposal document again - resp = document.put(data=proposal_doc.hex(), token=rbac_auth_token) - assert ( - resp.status_code == 204 - ), f"Failed to publish document: {resp.status_code} - {resp.text}" - # Get the proposal document - resp = document.get(document_id=proposal_doc_id, token=rbac_auth_token) + resp = document.get(document_id=proposal_doc_id, token=rbac_chain.auth_token()) assert ( resp.status_code == 200 ), f"Failed to get document: {resp.status_code} - {resp.text}" # Post a signed document with filter ID resp = document.post( - "/index", filter={"id": {"eq": proposal_doc_id}}, token=rbac_auth_token + "/index", filter={"id": {"eq": proposal_doc_id}}, token=rbac_chain.auth_token() ) assert ( resp.status_code == 200 ), f"Failed to post document: {resp.status_code} - {resp.text}" + # Put document with different ver + new_doc = proposal_doc.copy() + new_doc.metadata["ver"] = uuid_v7.uuid_v7() + resp = document.put( + data=new_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) + assert ( + resp.status_code == 201 + ), f"Failed to publish document: {resp.status_code} - {resp.text}" + + # Put a comment document again + resp = document.put( + data=new_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) + assert ( + resp.status_code == 204 + ), f"Failed to publish document: {resp.status_code} - {resp.text}" + # Put a proposal document with same ID different content invalid_doc = proposal_doc.copy() invalid_doc.content["setup"]["title"]["title"] = "another title" - resp = document.put(data=invalid_doc.hex(), token=rbac_auth_token) + resp = document.put( + data=invalid_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 422 ), f"Publish document, expected 422 Unprocessable Content: {resp.status_code} - {resp.text}" @@ -226,7 +263,10 @@ def test_proposal_doc(proposal_doc_factory, rbac_auth_token_factory): new_doc = proposal_doc.copy() new_doc.metadata["ver"] = uuid_v7.uuid_v7() new_doc.content["setup"]["title"]["title"] = "another title" - resp = document.put(data=new_doc.hex(), token=rbac_auth_token) + resp = document.put( + data=new_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 201 ), f"Failed to publish document: {resp.status_code} - {resp.text}" @@ -234,7 +274,10 @@ def test_proposal_doc(proposal_doc_factory, rbac_auth_token_factory): # Put a proposal document with the not known template field invalid_doc = proposal_doc.copy() invalid_doc.metadata["template"] = {"id": uuid_v7.uuid_v7()} - resp = document.put(data=invalid_doc.hex(), token=rbac_auth_token) + resp = document.put( + data=invalid_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 422 ), f"Publish document, expected 422 Unprocessable Content: {resp.status_code} - {resp.text}" @@ -243,7 +286,10 @@ def test_proposal_doc(proposal_doc_factory, rbac_auth_token_factory): invalid_doc = proposal_doc.copy() invalid_doc.metadata["ver"] = uuid_v7.uuid_v7() invalid_doc.content = {} - resp = document.put(data=invalid_doc.hex(), token=rbac_auth_token) + resp = document.put( + data=invalid_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 422 ), f"Publish document, expected 422 Unprocessable Content: {resp.status_code} - {resp.text}" @@ -251,36 +297,55 @@ def test_proposal_doc(proposal_doc_factory, rbac_auth_token_factory): logger.info("Proposal document test successful.") -def test_comment_doc(comment_doc_factory, rbac_auth_token_factory): - rbac_auth_token = rbac_auth_token_factory() +@pytest.mark.preprod_indexing +def test_comment_doc(comment_doc_factory, rbac_chain_factory): + rbac_chain = rbac_chain_factory(RoleID.ROLE_0) + (cat_id, sk_hex) = rbac_chain.cat_id_for_role(RoleID.ROLE_0) comment_doc = comment_doc_factory() comment_doc_id = comment_doc.metadata["id"] - # Put a comment document again - resp = document.put(data=comment_doc.hex(), token=rbac_auth_token) - assert ( - resp.status_code == 204 - ), f"Failed to publish document: {resp.status_code} - {resp.text}" - # Get the comment document - resp = document.get(document_id=comment_doc_id, token=rbac_auth_token) + resp = document.get(document_id=comment_doc_id, token=rbac_chain.auth_token()) assert ( resp.status_code == 200 ), f"Failed to get document: {resp.status_code} - {resp.text}" # Post a signed document with filter ID resp = document.post( - "/index", filter={"id": {"eq": comment_doc_id}}, token=rbac_auth_token + "/index", filter={"id": {"eq": comment_doc_id}}, token=rbac_chain.auth_token() ) assert ( resp.status_code == 200 ), f"Failed to post document: {resp.status_code} - {resp.text}" + # Put document with different ver + new_doc = comment_doc.copy() + new_doc.metadata["ver"] = uuid_v7.uuid_v7() + resp = document.put( + data=new_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) + assert ( + resp.status_code == 201 + ), f"Failed to publish document: {resp.status_code} - {resp.text}" + + # Put a comment document again + resp = document.put( + data=new_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) + assert ( + resp.status_code == 204 + ), f"Failed to publish document: {resp.status_code} - {resp.text}" + # Put a comment document with empty content invalid_doc = comment_doc.copy() invalid_doc.metadata["ver"] = uuid_v7.uuid_v7() invalid_doc.content = {} - resp = document.put(data=invalid_doc.hex(), token=rbac_auth_token) + resp = document.put( + data=invalid_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 422 ), f"Publish document, expected 422 Unprocessable Content: {resp.status_code} - {resp.text}" @@ -288,7 +353,10 @@ def test_comment_doc(comment_doc_factory, rbac_auth_token_factory): # Put a comment document referencing to the not known proposal invalid_doc = comment_doc.copy() invalid_doc.metadata["ref"] = {"id": uuid_v7.uuid_v7()} - resp = document.put(data=invalid_doc.hex(), token=rbac_auth_token) + resp = document.put( + data=invalid_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 422 ), f"Publish document, expected 422 Unprocessable Content: {resp.status_code} - {resp.text}" @@ -296,35 +364,59 @@ def test_comment_doc(comment_doc_factory, rbac_auth_token_factory): logger.info("Comment document test successful.") -def test_submission_action(submission_action_factory, rbac_auth_token_factory): - rbac_auth_token = rbac_auth_token_factory() +@pytest.mark.preprod_indexing +def test_submission_action(submission_action_factory, rbac_chain_factory): + rbac_chain = rbac_chain_factory(RoleID.PROPOSER) + (cat_id, sk_hex) = rbac_chain.cat_id_for_role(RoleID.PROPOSER) submission_action = submission_action_factory() submission_action_id = submission_action.metadata["id"] - # Put a submission action document - resp = document.put(data=submission_action.hex(), token=rbac_auth_token) - assert ( - resp.status_code == 204 - ), f"Failed to publish document: {resp.status_code} - {resp.text}" - # Get the submission action doc - resp = document.get(document_id=submission_action_id, token=rbac_auth_token) + resp = document.get( + document_id=submission_action_id, + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 200 ), f"Failed to get document: {resp.status_code} - {resp.text}" # Post a signed document with filter ID resp = document.post( - "/index", filter={"id": {"eq": submission_action_id}}, token=rbac_auth_token + "/index", + filter={"id": {"eq": submission_action_id}}, + token=rbac_chain.auth_token(), ) assert ( resp.status_code == 200 ), f"Failed to post document: {resp.status_code} - {resp.text}" + # Put document with different ver + new_doc = submission_action.copy() + new_doc.metadata["ver"] = uuid_v7.uuid_v7() + resp = document.put( + data=new_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) + assert ( + resp.status_code == 201 + ), f"Failed to publish document: {resp.status_code} - {resp.text}" + + # Put a comment document again + resp = document.put( + data=new_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) + assert ( + resp.status_code == 204 + ), f"Failed to publish document: {resp.status_code} - {resp.text}" + # Submission action document MUST have a ref invalid_doc = submission_action.copy() invalid_doc.metadata["ref"] = {} - resp = document.put(data=invalid_doc.hex(), token=rbac_auth_token) + resp = document.put( + data=invalid_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 422 ), f"Publish document, expected 422 Unprocessable Content: {resp.status_code} - {resp.text}" @@ -332,7 +424,10 @@ def test_submission_action(submission_action_factory, rbac_auth_token_factory): # Put a submission action document referencing an unknown proposal invalid_doc = submission_action.copy() invalid_doc.metadata["ref"] = {"id": uuid_v7.uuid_v7()} - resp = document.put(data=invalid_doc.hex(), token=rbac_auth_token) + resp = document.put( + data=invalid_doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 422 ), f"Publish document, expected 422 Unprocessable Content: {resp.status_code} - {resp.text}" @@ -340,8 +435,10 @@ def test_submission_action(submission_action_factory, rbac_auth_token_factory): logger.info("Submission action document test successful.") -def test_document_index_endpoint(proposal_doc_factory, rbac_auth_token_factory): - rbac_auth_token = rbac_auth_token_factory() +@pytest.mark.preprod_indexing +def test_document_index_endpoint(proposal_doc_factory, rbac_chain_factory): + rbac_chain = rbac_chain_factory(RoleID.PROPOSER) + (cat_id, sk_hex) = rbac_chain.cat_id_for_role(RoleID.PROPOSER) # submiting 10 proposal documents total_amount = 10 first_proposal = proposal_doc_factory() @@ -349,7 +446,10 @@ def test_document_index_endpoint(proposal_doc_factory, rbac_auth_token_factory): doc = first_proposal.copy() # keep the same id, but different version doc.metadata["ver"] = uuid_v7.uuid_v7() - resp = document.put(data=doc.hex(), token=rbac_auth_token) + resp = document.put( + data=doc.build_and_sign(cat_id, sk_hex), + token=rbac_chain.auth_token(), + ) assert ( resp.status_code == 201 ), f"Failed to publish document: {resp.status_code} - {resp.text}" @@ -358,7 +458,9 @@ def test_document_index_endpoint(proposal_doc_factory, rbac_auth_token_factory): page = 0 filter = {"id": {"eq": first_proposal.metadata["id"]}} resp = document.post( - f"/index?limit={limit}&page={page}", filter=filter, token=rbac_auth_token + f"/index?limit={limit}&page={page}", + filter=filter, + token=rbac_chain.auth_token(), ) assert ( resp.status_code == 200 @@ -371,19 +473,20 @@ def test_document_index_endpoint(proposal_doc_factory, rbac_auth_token_factory): page += 1 resp = document.post( - f"/index?limit={limit}&page={page}", filter=filter, token=rbac_auth_token + f"/index?limit={limit}&page={page}", + filter=filter, + token=rbac_chain.auth_token(), ) assert ( resp.status_code == 200 ), f"Failed to post document: {resp.status_code} - {resp.text}" data = resp.json() - print(data) assert data["page"]["limit"] == limit assert data["page"]["page"] == page assert data["page"]["remaining"] == total_amount - 1 - page resp = document.post( - f"/index?limit={total_amount}", filter=filter, token=rbac_auth_token + f"/index?limit={total_amount}", filter=filter, token=rbac_chain.auth_token() ) assert ( resp.status_code == 200 @@ -395,7 +498,7 @@ def test_document_index_endpoint(proposal_doc_factory, rbac_auth_token_factory): # Pagination out of range resp = document.post( - "/index?page=92233720368547759", filter={}, token=rbac_auth_token + "/index?page=92233720368547759", filter={}, token=rbac_chain.auth_token() ) assert ( resp.status_code == 412 diff --git a/catalyst-gateway/tests/api_tests/pyproject.toml b/catalyst-gateway/tests/api_tests/pyproject.toml index 65b5852faffa..e69183aa9b2a 100644 --- a/catalyst-gateway/tests/api_tests/pyproject.toml +++ b/catalyst-gateway/tests/api_tests/pyproject.toml @@ -1,4 +1,4 @@ -# cspell: words bitcoinlib +# cspell: words bitcoinlib addopts [tool.poetry] name = "api_tests" @@ -30,4 +30,6 @@ build-backend = "poetry.core.masonry.api" markers = [ "ci: marks tests to be run in ci", "nightly: marks tests to be run nightly", + "preprod_indexing: marks test which requires indexing of the cardano preprod network", ] +addopts = "-m 'not preprod_indexing'" diff --git a/catalyst-gateway/tests/api_tests/test_data/rbac_regs/only_role_0.jsonc b/catalyst-gateway/tests/api_tests/test_data/rbac_regs/only_role_0.jsonc new file mode 100644 index 000000000000..7ea49428c890 --- /dev/null +++ b/catalyst-gateway/tests/api_tests/test_data/rbac_regs/only_role_0.jsonc @@ -0,0 +1,8 @@ +{ + "0": { + "tx_id": "e4d3c4957cfb8a77bfb7be3e320af57b6d5b2f6f36a8564b6c7921cb9b6ffd39", + "sk": "600f662a02d72db06c21201e2e7810042419fa769f30b5be46f92e29e7e59341f03aa7eb2c89083569968ec621f1b0c8cdc392f4d895651cc29b168522066fbe65dcadfa4e257915afe0972ef01a805adabb725daf4b7eaa997f4a7181ebd512", + "pk": "d0a195a645d57b9fa1d517c86f4f0c9dcfb13194f3497e555673c3687be1aea465dcadfa4e257915afe0972ef01a805adabb725daf4b7eaa997f4a7181ebd512", + "rotation": 0 + } +} \ No newline at end of file diff --git a/catalyst-gateway/tests/api_tests/test_data/rbac_regs/role_3.jsonc b/catalyst-gateway/tests/api_tests/test_data/rbac_regs/role_3.jsonc new file mode 100644 index 000000000000..1dc0fb7d7346 --- /dev/null +++ b/catalyst-gateway/tests/api_tests/test_data/rbac_regs/role_3.jsonc @@ -0,0 +1,14 @@ +{ + "0": { + "tx_id": "5fd71fb559d3ebf16bb0b8b30028a1d0fbbb3a983dbbe2e92eb87f851c6d205c", + "sk": "a8f84dd9576f9b5224da38146df1dd4c80c6aa767cb71540bd86294f62cced568ecdf07352e0b48e1ae66370352e56aba4113461ec08e13b2fed10ecc056c65fd2a76829a3b53e66af79bb0cb1efade075f0ae65eaaabb75f5106bbeef59b866", + "pk": "42149f1a6f1da43fcf066a473e12515b5b6216fedfc52b87bee091456981d9c6d2a76829a3b53e66af79bb0cb1efade075f0ae65eaaabb75f5106bbeef59b866", + "rotation": 0 + }, + "3": { + "tx_id": "5fd71fb559d3ebf16bb0b8b30028a1d0fbbb3a983dbbe2e92eb87f851c6d205c", + "sk": "284b1a3b46f00d99193aefe80df3797cfcd88d1058203da1b3c695315bcced5665a53e06fb76f5a5d3a5122cbaa4eba94b81d3f4c7db7ace8bf6c7340a34bfc7995bb20c59d086d671cfac83177857761a4f4badd65fee96f3b8e351ebe217b8", + "pk": "ac36a7c87a77de72c3404cca36029e63cdd5cc6e7b2538a52908eee983011b51995bb20c59d086d671cfac83177857761a4f4badd65fee96f3b8e351ebe217b8", + "rotation": 1 + } +} \ No newline at end of file diff --git a/catalyst-gateway/tests/api_tests/utils/auth_token.py b/catalyst-gateway/tests/api_tests/utils/auth_token.py deleted file mode 100644 index a355dffe363c..000000000000 --- a/catalyst-gateway/tests/api_tests/utils/auth_token.py +++ /dev/null @@ -1,40 +0,0 @@ -from datetime import datetime, timezone -import base64 -import pytest -from pycardano.crypto.bip32 import BIP32ED25519PrivateKey, BIP32ED25519PublicKey - -@pytest.fixture -def rbac_auth_token_factory(): - - def __rbac_auth_token_factory( - # Already registered as role 0 - # https://preprod.cexplorer.io/tx/5fd71fb559d3ebf16bb0b8b30028a1d0fbbb3a983dbbe2e92eb87f851c6d205c - sk_hex: str = "a8f84dd9576f9b5224da38146df1dd4c80c6aa767cb71540bd86294f62cced568ecdf07352e0b48e1ae66370352e56aba4113461ec08e13b2fed10ecc056c65fd2a76829a3b53e66af79bb0cb1efade075f0ae65eaaabb75f5106bbeef59b866", - pk_hex: str = "42149f1a6f1da43fcf066a473e12515b5b6216fedfc52b87bee091456981d9c6d2a76829a3b53e66af79bb0cb1efade075f0ae65eaaabb75f5106bbeef59b866" - ) -> str: - pk = bytes.fromhex(pk_hex)[:32] - sk = bytes.fromhex(sk_hex)[:64] - chain_code = bytes.fromhex(sk_hex)[64:] - return generate_rbac_auth_token("cardano", "preprod", pk, sk, chain_code) - - return __rbac_auth_token_factory - -def generate_rbac_auth_token(network: str, subnet: str | None, pk: bytes, sk: bytes, chain_code: bytes) -> str: - bip32_ed25519_sk = BIP32ED25519PrivateKey(sk, chain_code) - bip32_ed25519_pk = BIP32ED25519PublicKey(pk, chain_code) - - prefix = "catid.:" - nonce = int(datetime.now(timezone.utc).timestamp()) - subnet = f"{subnet}." if subnet else "" - role0_pk_b64 = base64_url(pk) - catid_without_sig = f"{prefix}{nonce}@{subnet}{network}/{role0_pk_b64}." - - signature = bip32_ed25519_sk.sign(catid_without_sig.encode()) - bip32_ed25519_pk.verify(signature, catid_without_sig.encode()) - signature_b64 = base64_url(signature) - - return f"{catid_without_sig}{signature_b64}" - -def base64_url(data: bytes) -> str: - # URL safety and no padding base 64 - return base64.urlsafe_b64encode(data).decode().rstrip("=") diff --git a/catalyst-gateway/tests/api_tests/utils/rbac_chain.py b/catalyst-gateway/tests/api_tests/utils/rbac_chain.py new file mode 100644 index 000000000000..902be2ba04d1 --- /dev/null +++ b/catalyst-gateway/tests/api_tests/utils/rbac_chain.py @@ -0,0 +1,104 @@ +from datetime import datetime, timezone +import base64 +import pytest +from enum import IntEnum +import json +from pycardano.crypto.bip32 import BIP32ED25519PrivateKey, BIP32ED25519PublicKey + +with open("./test_data/rbac_regs/only_role_0.jsonc", "r") as f: + ONLY_ROLE_0_REG_JSON = json.load(f) +with open("./test_data/rbac_regs/role_3.jsonc", "r") as f: + ROLE_3_REG_JSON = json.load(f) + + +class RoleID(IntEnum): + ROLE_0 = 0 + PROPOSER = 3 + + def __str__(self): + return f"{int(self)}" + + +class RBACChain: + def __init__(self, keys_map: dict, network: str): + # corresponded to different roles bip32 extended ed25519 keys map + self.keys_map = keys_map + self.network = network + + def auth_token(self) -> str: + role_0_keys = self.keys_map[f"{RoleID.ROLE_0}"] + return generate_rbac_auth_token( + "cardano", self.network, role_0_keys["pk"], role_0_keys["sk"] + ) + + # returns a role's catalyst id, with the provided role secret key + def cat_id_for_role(self, role_id: RoleID) -> (str, str): + role_data = self.keys_map[f"{role_id}"] + role_0_pk = self.keys_map[f"{RoleID.ROLE_0}"]["pk"] + return ( + generate_cat_id( + "cardano", + self.network, + role_id, + role_0_pk, + role_data["rotation"], + ), + role_data["sk"], + ) + + +@pytest.fixture +def rbac_chain_factory(): + def __rbac_chain_factory(role_id: RoleID) -> RBACChain: + network = "preprod" + match role_id: + # RBAC registration chain that contains only Role 0 (voter) + case RoleID.ROLE_0: + return RBACChain(ONLY_ROLE_0_REG_JSON, network) + # RBAC registration chain that contains both Role 0 -> Role 3 (proposer) + case RoleID.PROPOSER: + return RBACChain(ROLE_3_REG_JSON, network) + + return __rbac_chain_factory + + +def generate_cat_id( + network: str, subnet: str, role_id: RoleID, pk_hex: str, rotation: int +): + pk = bytes.fromhex(pk_hex)[:32] + prefix = "catid.:" + nonce = int(datetime.now(timezone.utc).timestamp()) + subnet = f"{subnet}." if subnet else "" + role0_pk_b64 = base64_url(pk) + + if role_id == RoleID.ROLE_0 and rotation == 0: + return f"{prefix}{nonce}@{subnet}{network}/{role0_pk_b64}" + + return f"{prefix}{nonce}@{subnet}{network}/{role0_pk_b64}/{role_id}/{rotation}" + + +def generate_rbac_auth_token( + network: str, + subnet: str, + pk_hex: str, + sk_hex: str, +) -> str: + pk = bytes.fromhex(pk_hex)[:32] + sk = bytes.fromhex(sk_hex)[:64] + chain_code = bytes.fromhex(sk_hex)[64:] + + bip32_ed25519_sk = BIP32ED25519PrivateKey(sk, chain_code) + bip32_ed25519_pk = BIP32ED25519PublicKey(pk, chain_code) + + cat_id = generate_cat_id(network, subnet, RoleID.ROLE_0, pk_hex, 0) + + signature = bip32_ed25519_sk.sign(cat_id.encode()) + bip32_ed25519_pk.verify(signature, cat_id.encode()) + signature_b64 = base64_url(signature) + + return f"{cat_id}.{signature_b64}" + + +def base64_url(data: bytes) -> str: + # URL safety and no padding base 64 + return base64.urlsafe_b64encode(data).decode().rstrip("=") diff --git a/catalyst-gateway/tests/api_tests/utils/signed_doc.py b/catalyst-gateway/tests/api_tests/utils/signed_doc.py index 1f0f7594e54d..40d9454d9fdd 100644 --- a/catalyst-gateway/tests/api_tests/utils/signed_doc.py +++ b/catalyst-gateway/tests/api_tests/utils/signed_doc.py @@ -5,7 +5,11 @@ def build_signed_doc( - metadata_json: Dict[str, Any], doc_content_json: Dict[str, Any] + metadata_json: Dict[str, Any], + doc_content_json: Dict[str, Any], + bip32_sk_hex: str, + # corresponded to the `bip32_sk_hex` `cat_id` string + cat_id: str, ) -> str: with NamedTemporaryFile() as metadata_file, NamedTemporaryFile() as doc_content_file, NamedTemporaryFile() as signed_doc_file: @@ -27,5 +31,15 @@ def build_signed_doc( ], ) + subprocess.run( + [ + "./mk_signed_doc", + "sign", + signed_doc_file.name, + bip32_sk_hex, + cat_id, + ] + ) + signed_doc_hex = signed_doc_file.read().hex() return signed_doc_hex