From a6b2b26a6efe44307ce22bfe5db9da64b1702f2f Mon Sep 17 00:00:00 2001 From: Mr-Leshiy Date: Thu, 3 Apr 2025 10:30:20 +0300 Subject: [PATCH 1/8] add signed doc time based validation --- catalyst-gateway/bin/Cargo.toml | 10 ++-- .../service/api/documents/put_document/mod.rs | 11 +++- catalyst-gateway/bin/src/settings/mod.rs | 10 ++++ .../bin/src/settings/signed_doc.rs | 57 +++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 catalyst-gateway/bin/src/settings/signed_doc.rs diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index 1414e935898e..24ec1dd27442 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -15,11 +15,11 @@ repository.workspace = true workspace = true [dependencies] -cardano-chain-follower = { version = "0.0.8", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250401-01" } -rbac-registration = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250401-01" } -catalyst-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250401-01" } -cardano-blockchain-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250401-01" } -catalyst-signed-doc = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250401-01" } +cardano-chain-follower = { version = "0.0.8", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } +rbac-registration = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } +catalyst-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } +cardano-blockchain-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } +catalyst-signed-doc = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } pallas = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } pallas-traverse = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } 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..d150e9c897f6 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 @@ -8,6 +8,7 @@ use super::get_document::DocProvider; use crate::{ db::event::signed_docs::{FullSignedDoc, SignedDocBody, StoreError}, service::common::responses::WithErrorResponses, + settings::Settings, }; pub(crate) mod unprocessable_content_request; @@ -46,9 +47,17 @@ pub(crate) type AllResponses = WithErrorResponses; /// # PUT `/document` pub(crate) async fn endpoint(doc_bytes: Vec) -> AllResponses { + let signed_doc_cfg = Settings::signed_doc_cfg(); match doc_bytes.as_slice().try_into() { Ok(doc) => { - if let Err(e) = catalyst_signed_doc::validator::validate(&doc, &DocProvider).await { + if let Err(e) = catalyst_signed_doc::validator::validate( + &doc, + signed_doc_cfg.future_threshold(), + signed_doc_cfg.past_threshold(), + &DocProvider, + ) + .await + { // means that something happened inside the `DocProvider`, some db error. return AllResponses::handle_error(&e); } diff --git a/catalyst-gateway/bin/src/settings/mod.rs b/catalyst-gateway/bin/src/settings/mod.rs index f2ff28d77079..78e79a6677c3 100644 --- a/catalyst-gateway/bin/src/settings/mod.rs +++ b/catalyst-gateway/bin/src/settings/mod.rs @@ -23,6 +23,7 @@ use crate::{ pub(crate) mod cassandra_db; pub(crate) mod chain_follower; +pub(crate) mod signed_doc; mod str_env_var; /// Default address to start service on, '0.0.0.0:3030'. @@ -136,6 +137,9 @@ struct EnvVars { /// The Chain Follower configuration chain_follower: chain_follower::EnvVars, + /// The Catalyst Signed Documents configuration + signed_doc: signed_doc::EnvVars, + /// Internal API Access API Key internal_api_key: Option, @@ -211,6 +215,7 @@ static ENV_VARS: LazyLock = LazyLock::new(|| { cassandra_db::VOLATILE_NAMESPACE_DEFAULT, ), chain_follower: chain_follower::EnvVars::new(), + signed_doc: signed_doc::EnvVars::new(), internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true), check_config_tick: StringEnvVar::new_as_duration( "CHECK_CONFIG_TICK", @@ -307,6 +312,11 @@ impl Settings { ENV_VARS.chain_follower.clone() } + /// Get the configuration of the Catalyst Signed Documents. + pub(crate) fn signed_doc_cfg() -> signed_doc::EnvVars { + ENV_VARS.signed_doc.clone() + } + /// Chain Follower network (The Blockchain network we are configured to use). /// Note: Catalyst Gateway can ONLY follow one network at a time. pub(crate) fn cardano_network() -> Network { diff --git a/catalyst-gateway/bin/src/settings/signed_doc.rs b/catalyst-gateway/bin/src/settings/signed_doc.rs new file mode 100644 index 000000000000..c7ac8a70caf9 --- /dev/null +++ b/catalyst-gateway/bin/src/settings/signed_doc.rs @@ -0,0 +1,57 @@ +//! Command line and environment variable settings for the Catalyst Signed Docs + +use super::str_env_var::StringEnvVar; + +/// Default number valud of `future_threshold`, 30 seconds. +const DEFAULT_FUTURE_THRESHOLD: u64 = 30; + +/// Default number valud of `future_threshold`, 10 minutes. +const DEFAULT_PAST_THRESHOLD: u64 = 10 * 60; + +/// Configuration for the Catalyst Signed Documents validation configuration. +#[derive(Clone)] +pub(crate) struct EnvVars { + /// The Catalyst Signed Document threshold, document cannot be too far in the future + /// (in seconds). + future_threshold: u64, + + /// The Catalyst Signed Document threshold, document cannot be too far behind + /// (in seconds). + past_threshold: u64, +} + +impl EnvVars { + /// Create a config for Catalyst Signed Document validation configuration. + pub(super) fn new() -> Self { + let future_threshold: u64 = StringEnvVar::new_as_int( + "SIGNED_DOC_FUTURE_THRESHOLD", + DEFAULT_FUTURE_THRESHOLD, + 0, + u64::MAX, + ); + + let past_threshold: u64 = StringEnvVar::new_as_int( + "SIGNED_DOC_PAST_THRESHOLD", + DEFAULT_PAST_THRESHOLD, + 0, + u64::MAX, + ); + + Self { + future_threshold, + past_threshold, + } + } + + /// The Catalyst Signed Document threshold, document cannot be too far in the future + /// (in seconds). + pub(crate) fn future_threshold(&self) -> u64 { + self.future_threshold + } + + /// The Catalyst Signed Document threshold, document cannot be too far behind + /// (in seconds). + pub(crate) fn past_threshold(&self) -> u64 { + self.past_threshold + } +} From e02cbbd21ce7789fec1df3fc7684061d2c2054e6 Mon Sep 17 00:00:00 2001 From: Mr-Leshiy Date: Thu, 3 Apr 2025 10:40:10 +0300 Subject: [PATCH 2/8] fix spelling --- catalyst-gateway/bin/src/settings/signed_doc.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catalyst-gateway/bin/src/settings/signed_doc.rs b/catalyst-gateway/bin/src/settings/signed_doc.rs index c7ac8a70caf9..d0a25c5ba8c4 100644 --- a/catalyst-gateway/bin/src/settings/signed_doc.rs +++ b/catalyst-gateway/bin/src/settings/signed_doc.rs @@ -2,10 +2,10 @@ use super::str_env_var::StringEnvVar; -/// Default number valud of `future_threshold`, 30 seconds. +/// Default number value of `future_threshold`, 30 seconds. const DEFAULT_FUTURE_THRESHOLD: u64 = 30; -/// Default number valud of `future_threshold`, 10 minutes. +/// Default number value of `future_threshold`, 10 minutes. const DEFAULT_PAST_THRESHOLD: u64 = 10 * 60; /// Configuration for the Catalyst Signed Documents validation configuration. From 2c0481034f29487ef88b1808a77da949caa97812 Mon Sep 17 00:00:00 2001 From: Mr-Leshiy Date: Thu, 3 Apr 2025 13:00:39 +0300 Subject: [PATCH 3/8] wip --- catalyst-gateway/bin/Cargo.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index 24ec1dd27442..443e6d0b5d48 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -15,11 +15,11 @@ repository.workspace = true workspace = true [dependencies] -cardano-chain-follower = { version = "0.0.8", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } -rbac-registration = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } -catalyst-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } -cardano-blockchain-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } -catalyst-signed-doc = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", branch = "feat/doc-id-ver-timestamp-validation" } +cardano-chain-follower = { version = "0.0.8", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } +rbac-registration = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } +catalyst-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } +cardano-blockchain-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } +catalyst-signed-doc = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } pallas = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } pallas-traverse = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } From f36d42347bcbd9bbc7adc3c558b6de575ca2bd39 Mon Sep 17 00:00:00 2001 From: Alex Pozhylenkov Date: Fri, 4 Apr 2025 17:35:12 +0300 Subject: [PATCH 4/8] feat(cat-gateway): Store `RegistrationChain` as a part of the RBAC token (#2123) * store reg_chain as a part of the RBAC token * Update catalyst-gateway/bin/src/service/common/auth/rbac/token.rs Co-authored-by: Steven Johnson * feat: error handling and cloneable reg_chain * feat: service unavailable for rbac token auth * chore: comment correction * chore: lintfix * chore: cspellfix * chore: move helper function --------- Co-authored-by: Apisit Ritruengroj <38898766+apskhem@users.noreply.github.com> Co-authored-by: Steven Johnson Co-authored-by: Apisit Ritreungroj --- catalyst-gateway/bin/Cargo.toml | 10 +- .../queries/rbac/get_rbac_registrations.rs | 83 +++++++- catalyst-gateway/bin/src/db/index/session.rs | 4 + .../src/service/common/auth/rbac/scheme.rs | 198 +++++------------- .../bin/src/service/common/auth/rbac/token.rs | 28 +++ 5 files changed, 173 insertions(+), 150 deletions(-) diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index 443e6d0b5d48..d6e933c5e456 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -15,11 +15,11 @@ repository.workspace = true workspace = true [dependencies] -cardano-chain-follower = { version = "0.0.8", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } -rbac-registration = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } -catalyst-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } -cardano-blockchain-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } -catalyst-signed-doc = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-00" } +cardano-chain-follower = { version = "0.0.8", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-01" } +rbac-registration = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-01" } +catalyst-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-01" } +cardano-blockchain-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-01" } +catalyst-signed-doc = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250403-01" } pallas = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } pallas-traverse = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } 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 fde4dd7116fc..f2f11c2f1843 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/common/auth/rbac/scheme.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs index c66d709e20d5..dc50cdd0a003 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 @@ -62,6 +47,29 @@ static CACHE: LazyLock> = LazyLock: #[allow(dead_code, clippy::module_name_repetitions)] pub struct CatalystRBACSecurityScheme(pub CatalystRBACTokenV1); +/// 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() + } +} + /// Error with the Authorization Token /// /// We can not parse it, so its a 401 response. @@ -118,7 +126,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 })?; @@ -128,13 +136,13 @@ 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() - ); + // Step 6: get and build latest registration chain from the db. + if let Err(err) = token.reg_chain_mut().await { + if err.is::() { + return Err(ServiceUnavailableError(err).into()); + } + + error!("Unable to build a registration chain Catalyst ID: {err:?}"); return Err(AuthTokenError.into()); } @@ -145,6 +153,14 @@ async fn checker_api_catalyst_auth( Err(AuthTokenAccessViolation(vec!["EXPIRED".to_string()]))?; } + let Some(reg_chain) = token.reg_chain() else { + error!( + "Unable to find registrations for {} Catalyst ID", + token.catalyst_id() + ); + return Err(AuthTokenError.into()); + }; + // TODO: Caching is currently disabled because we want to measure the performance without // it. // // Its valid and young enough, check if its in the auth cache. @@ -157,20 +173,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() @@ -187,111 +202,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 a473cf89c1d6..65938a206fc0 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,6 +167,23 @@ impl CatalystRBACTokenV1 { pub(crate) fn network(&self) -> Network { self.network } + + /// Returns a corresponded registration chain. + pub(crate) fn reg_chain(&self) -> Option<&RegistrationChain> { + self.reg_chain.as_ref() + } + + /// 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(&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.as_ref()) + } } impl Display for CatalystRBACTokenV1 { From 47599310c2be4deef2231862bfbb769dd564815f Mon Sep 17 00:00:00 2001 From: Apisit Ritruengroj <38898766+apskhem@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:26:49 +0700 Subject: [PATCH 5/8] feat(cat-gateway): Add signature validation layer to `PUT /v1/document` (#2038) * feat: initial doc signature validation * feat: validation * feat: doc report including * chore: use if guard * chore: leaner matching pattern * feat: from token * feat: use from token * feat: rbac token and doc kids validation * chore: minor error message * chore: eq compare * feat: tmp trying new role records * feat: applying latest rbac lib * chore: scheme * feat: complete tmp flow * feat: latest rotation validation * fix: id comparison * fix: short id * fix: kid compare logic * chore: minor * chore: fix cspell * chore: fmtfix * fix: proposer role to 3 * fix: get latest key dynamically by role * fix: finding latest keys by kids instead of using auth token * feat: doc id/ver checking * feat: different document ver/id authors validation * chore: error message * chore: minor * refactor: lintfix * fix: validation condition * chore: move token and kids validation * chore: try_from_kids doc comment * chore: fmtfix * chore: remove redundant step comment * chore: apply reg chain initialization for key provider building * chore: minor * fix: kid validations * chore: cspellfix * fix: try_from_kids doc comments * chore: use map * feat: add validation for single kid only * chore: remove comment * chore: fix function doc * wip --------- Co-authored-by: Mr-Leshiy --- .../db/event/signed_docs/signed_doc_body.rs | 5 + .../src/service/api/documents/common/mod.rs | 143 +++++++++++++ .../src/service/api/documents/get_document.rs | 50 +---- .../bin/src/service/api/documents/mod.rs | 5 +- .../service/api/documents/put_document/mod.rs | 188 +++++++++++++----- .../src/service/common/auth/rbac/scheme.rs | 1 - 6 files changed, 288 insertions(+), 104 deletions(-) create mode 100644 catalyst-gateway/bin/src/service/api/documents/common/mod.rs 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/service/api/documents/common/mod.rs b/catalyst-gateway/bin/src/service/api/documents/common/mod.rs new file mode 100644 index 000000000000..ae2fcb3332db --- /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.map(|uuid| uuid.uuid()); + match get_document(&id, ver.as_ref()).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) fn try_from_kids( + token: &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() 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 e74eeb6d1231..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.map(|uuid| uuid.uuid()); - match get_document(&id, ver.as_ref()).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..58afe6ed4dcc 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.0).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..fe88ca3fe1e8 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,17 @@ //! Implementation of the PUT `/document` endpoint -use anyhow::anyhow; +use std::str::FromStr; + 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}, + }, + service::common::{auth::rbac::token::CatalystRBACTokenV1, responses::WithErrorResponses}, }; pub(crate) mod unprocessable_content_request; @@ -45,53 +49,146 @@ 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, 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) => { + // 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(&token, &doc.kids()) { + Ok(value) => value, + Err(err) => { + return Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( + &err, None, + ))) + .into() }, - Err(_) => { + }; + 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(); + } + + // check if the incoming doc and the current latest doc are the same ver/id + let (Ok(doc_id), Ok(doc_ver)) = (doc.doc_id(), doc.doc_ver()) else { + 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_id, doc_ver, &doc.kids()).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), } } +/// Checks if the document ID and version differ, fetch the latest version and ensure its +/// catalyst-id and role entries match those in the newer or different version. +async fn validate_against_original_doc( + doc_id: catalyst_signed_doc::UuidV7, doc_ver: catalyst_signed_doc::UuidV7, + kids: &[catalyst_signed_doc::IdUri], +) -> anyhow::Result { + let original_doc = match FullSignedDoc::retrieve(&doc_id.uuid(), None).await { + Ok(doc) => Some(doc), + Err(e) if e.is::() => None, + Err(e) => anyhow::bail!("Database error: {e}"), + }; + if let Some(original_doc) = original_doc { + if original_doc.id() != &doc_id.uuid() || original_doc.ver() != &doc_ver.uuid() { + let Ok(original_authors) = original_doc + .body() + .authors() + .iter() + .map(|author| catalyst_signed_doc::IdUri::from_str(author)) + .collect::, _>>() + else { + anyhow::bail!("Parsing db document error") + }; + + let result = original_authors.iter().all(|original_author| { + let found = kids.iter().find(|incoming_author| { + incoming_author.as_short_id() == original_author.as_short_id() + }); + + found.is_some_and(|incoming_author| { + incoming_author.role_and_rotation().0 == original_author.role_and_rotation().0 + }) + }); + + return Ok(result); + } + } + + Ok(true) +} + /// 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 +223,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/rbac/scheme.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs index dc50cdd0a003..0d9e752e604e 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs @@ -160,7 +160,6 @@ async fn checker_api_catalyst_auth( ); return Err(AuthTokenError.into()); }; - // TODO: Caching is currently disabled because we want to measure the performance without // it. // // Its valid and young enough, check if its in the auth cache. From d6ecf5d206d258c407a0b5a9868ab66822157d78 Mon Sep 17 00:00:00 2001 From: Alex Pozhylenkov Date: Wed, 9 Apr 2025 22:02:43 +0900 Subject: [PATCH 6/8] feat(cat-gateway): Disable Catalyst Signed Documents integration tests (#2225) * wip * disable signed documents integration tests in CI * wip --- .../src/service/api/documents/common/mod.rs | 6 +-- .../service/api/documents/put_document/mod.rs | 38 ++++++++++++------- .../src/service/common/auth/rbac/scheme.rs | 32 ++++++++-------- .../bin/src/service/common/auth/rbac/token.rs | 10 +---- .../api_tests/integration/test_signed_doc.py | 4 ++ 5 files changed, 50 insertions(+), 40 deletions(-) diff --git a/catalyst-gateway/bin/src/service/api/documents/common/mod.rs b/catalyst-gateway/bin/src/service/api/documents/common/mod.rs index ae2fcb3332db..964c178a9034 100644 --- a/catalyst-gateway/bin/src/service/api/documents/common/mod.rs +++ b/catalyst-gateway/bin/src/service/api/documents/common/mod.rs @@ -95,8 +95,8 @@ impl VerifyingKeyProvider { /// - 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) fn try_from_kids( - token: &CatalystRBACTokenV1, kids: &[catalyst_signed_doc::IdUri], + 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"); @@ -109,7 +109,7 @@ impl VerifyingKeyProvider { anyhow::bail!("RBAC Token CatID does not match with the document KIDs"); } - let Some(reg_chain) = token.reg_chain() else { + let Some(reg_chain) = token.reg_chain().await? else { anyhow::bail!("Failed to retrieve a registration from corresponding Catalyst ID"); }; 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 fe88ca3fe1e8..cb8d73987937 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 @@ -7,11 +7,17 @@ use unprocessable_content_request::PutDocumentUnprocessableContent; use super::common::{DocProvider, VerifyingKeyProvider}; use crate::{ - db::event::{ - error, - signed_docs::{FullSignedDoc, SignedDocBody, StoreError}, + db::{ + event::{ + error, + signed_docs::{FullSignedDoc, SignedDocBody, StoreError}, + }, + index::session::CassandraSessionError, + }, + service::common::{ + auth::rbac::token::CatalystRBACTokenV1, responses::WithErrorResponses, + types::headers::retry_after::RetryAfterOption, }, - service::common::{auth::rbac::token::CatalystRBACTokenV1, responses::WithErrorResponses}, }; pub(crate) mod unprocessable_content_request; @@ -49,7 +55,7 @@ pub(crate) enum Responses { pub(crate) type AllResponses = WithErrorResponses; /// # PUT `/document` -pub(crate) async fn endpoint(doc_bytes: Vec, token: CatalystRBACTokenV1) -> AllResponses { +pub(crate) async fn endpoint(doc_bytes: Vec, mut token: CatalystRBACTokenV1) -> AllResponses { let Ok(doc): Result = doc_bytes.as_slice().try_into() else { @@ -77,15 +83,19 @@ pub(crate) async fn endpoint(doc_bytes: Vec, token: CatalystRBACTokenV1) -> } // validate document signatures - let verifying_key_provider = match VerifyingKeyProvider::try_from_kids(&token, &doc.kids()) { - Ok(value) => value, - Err(err) => { - return Responses::UnprocessableContent(Json(PutDocumentUnprocessableContent::new( - &err, None, - ))) - .into() - }, - }; + 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) => { 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 0d9e752e604e..c8f4f9dbcde0 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs @@ -137,14 +137,23 @@ async fn checker_api_catalyst_auth( }; // Step 6: get and build latest registration chain from the db. - if let Err(err) = token.reg_chain_mut().await { - if err.is::() { - return Err(ServiceUnavailableError(err).into()); - } - - error!("Unable to build a registration chain Catalyst ID: {err:?}"); - return Err(AuthTokenError.into()); - } + 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) { @@ -153,13 +162,6 @@ async fn checker_api_catalyst_auth( Err(AuthTokenAccessViolation(vec!["EXPIRED".to_string()]))?; } - let Some(reg_chain) = token.reg_chain() else { - error!( - "Unable to find registrations for {} Catalyst ID", - token.catalyst_id() - ); - return Err(AuthTokenError.into()); - }; // TODO: Caching is currently disabled because we want to measure the performance without // it. // // Its valid and young enough, check if its in the auth cache. 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 65938a206fc0..232839db36c4 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs @@ -168,21 +168,15 @@ impl CatalystRBACTokenV1 { self.network } - /// Returns a corresponded registration chain. - pub(crate) fn reg_chain(&self) -> Option<&RegistrationChain> { - self.reg_chain.as_ref() - } - /// 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(&mut self) -> anyhow::Result> { + 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.as_ref()) + Ok(self.reg_chain.clone()) } } 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 3ebe03273457..fff1b89e4cd4 100644 --- a/catalyst-gateway/tests/api_tests/integration/test_signed_doc.py +++ b/catalyst-gateway/tests/api_tests/integration/test_signed_doc.py @@ -173,6 +173,7 @@ def test_templates(proposal_templates, comment_templates, rbac_auth_token_factor ), f"Failed to get document: {resp.status_code} - {resp.text} for id {template_id}" +@pytest.mark.skip("Enable when it will ready run Cardano indexing in CI") def test_proposal_doc(proposal_doc_factory, rbac_auth_token_factory): rbac_auth_token = rbac_auth_token_factory() proposal_doc = proposal_doc_factory() @@ -235,6 +236,7 @@ def test_proposal_doc(proposal_doc_factory, rbac_auth_token_factory): logger.info("Proposal document test successful.") +@pytest.mark.skip("Enable when it will ready run Cardano indexing in CI") def test_comment_doc(comment_doc_factory, rbac_auth_token_factory): rbac_auth_token = rbac_auth_token_factory() comment_doc = comment_doc_factory() @@ -280,6 +282,7 @@ def test_comment_doc(comment_doc_factory, rbac_auth_token_factory): logger.info("Comment document test successful.") +@pytest.mark.skip("Enable when it will ready run Cardano indexing in CI") def test_submission_action(submission_action_factory, rbac_auth_token_factory): rbac_auth_token = rbac_auth_token_factory() submission_action = submission_action_factory() @@ -324,6 +327,7 @@ def test_submission_action(submission_action_factory, rbac_auth_token_factory): logger.info("Submission action document test successful.") +@pytest.mark.skip("Enable when it will ready run Cardano indexing in CI") def test_document_index_endpoint(proposal_doc_factory, rbac_auth_token_factory): rbac_auth_token = rbac_auth_token_factory() # submiting 10 proposal documents From 049c1f70fa06b5bea2c2f6f277ea25a8462f006d Mon Sep 17 00:00:00 2001 From: Alex Pozhylenkov Date: Thu, 10 Apr 2025 21:09:50 +0900 Subject: [PATCH 7/8] wip (#2246) --- .../bin/src/service/api/cardano/rbac/mod.rs | 4 ++-- .../api/cardano/rbac/registrations_get/mod.rs | 14 +++++++++----- .../bin/src/service/api/documents/mod.rs | 2 +- .../bin/src/service/common/auth/none_or_rbac.rs | 8 +++++--- .../bin/src/service/common/auth/rbac/scheme.rs | 6 +++--- .../bin/src/service/common/auth/rbac/token.rs | 6 ------ 6 files changed, 20 insertions(+), 20 deletions(-) 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 b62cf25edfeb..ae57d90cbf70 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/mod.rs b/catalyst-gateway/bin/src/service/api/documents/mod.rs index 58afe6ed4dcc..a65546a0d50e 100644 --- a/catalyst-gateway/bin/src/service/api/documents/mod.rs +++ b/catalyst-gateway/bin/src/service/api/documents/mod.rs @@ -85,7 +85,7 @@ impl DocumentApi { 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(), auth.0).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/common/auth/none_or_rbac.rs b/catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs index 43c5b86e59dd..7bc9f9d78acd 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,9 +1,11 @@ //! Either has No Authorization, or RBAC Token. -use catalyst_types::id_uri::IdUri; use poem_openapi::SecurityScheme; -use super::{none::NoAuthorization, rbac::scheme::CatalystRBACSecurityScheme}; +use super::{ + none::NoAuthorization, + rbac::{scheme::CatalystRBACSecurityScheme, token::CatalystRBACTokenV1}, +}; #[derive(SecurityScheme)] #[allow(dead_code, clippy::upper_case_acronyms, clippy::large_enum_variant)] @@ -15,7 +17,7 @@ pub(crate) enum NoneOrRBAC { None(NoAuthorization), } -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 39750a2ca799..95435275940e 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs @@ -45,11 +45,11 @@ 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 } } 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 270e53a2fda6..232839db36c4 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs @@ -180,12 +180,6 @@ impl CatalystRBACTokenV1 { } } -impl From for IdUri { - fn from(value: CatalystRBACTokenV1) -> Self { - value.catalyst_id - } -} - impl Display for CatalystRBACTokenV1 { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( From 16f21743af5c2d3468f67bffc67d7e54b9bda8f8 Mon Sep 17 00:00:00 2001 From: Alex Pozhylenkov Date: Wed, 16 Apr 2025 14:37:08 +0900 Subject: [PATCH 8/8] feat(cat-gateway): Signed Documents signature validation test (#2269) * wip * wip * wip * wip * wip * fix * wip * wip * wip * wip * wip * wip * wip * wip * wip * revert * fix spelling * update readme.md * wip * wip * fix spelling * wip --- .../service/api/documents/put_document/mod.rs | 61 ++--- catalyst-gateway/tests/api_tests/Earthfile | 2 +- catalyst-gateway/tests/api_tests/Readme.md | 40 ++- .../tests/api_tests/api/v1/document.py | 17 +- .../api_tests/integration/test_signed_doc.py | 231 +++++++++++++----- .../tests/api_tests/pyproject.toml | 4 +- .../test_data/rbac_regs/only_role_0.jsonc | 8 + .../test_data/rbac_regs/role_3.jsonc | 14 ++ .../tests/api_tests/utils/auth_token.py | 40 --- .../tests/api_tests/utils/rbac_chain.py | 104 ++++++++ .../tests/api_tests/utils/signed_doc.py | 16 +- 11 files changed, 368 insertions(+), 169 deletions(-) create mode 100644 catalyst-gateway/tests/api_tests/test_data/rbac_regs/only_role_0.jsonc create mode 100644 catalyst-gateway/tests/api_tests/test_data/rbac_regs/role_3.jsonc delete mode 100644 catalyst-gateway/tests/api_tests/utils/auth_token.py create mode 100644 catalyst-gateway/tests/api_tests/utils/rbac_chain.py 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 cb8d73987937..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,7 +1,8 @@ //! Implementation of the PUT `/document` endpoint -use std::str::FromStr; +use std::{collections::HashSet, str::FromStr}; +use catalyst_signed_doc::CatalystSignedDocument; use poem_openapi::{payload::Json, ApiResponse}; use unprocessable_content_request::PutDocumentUnprocessableContent; @@ -118,15 +119,7 @@ pub(crate) async fn endpoint(doc_bytes: Vec, mut token: CatalystRBACTokenV1) .into(); } - // check if the incoming doc and the current latest doc are the same ver/id - let (Ok(doc_id), Ok(doc_ver)) = (doc.doc_id(), doc.doc_ver()) else { - 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_id, doc_ver, &doc.kids()).await { + match validate_against_original_doc(&doc).await { Ok(true) => (), Ok(false) => { return Responses::UnprocessableContent(Json( @@ -152,44 +145,22 @@ pub(crate) async fn endpoint(doc_bytes: Vec, mut token: CatalystRBACTokenV1) } } -/// Checks if the document ID and version differ, fetch the latest version and ensure its -/// catalyst-id and role entries match those in the newer or different version. -async fn validate_against_original_doc( - doc_id: catalyst_signed_doc::UuidV7, doc_ver: catalyst_signed_doc::UuidV7, - kids: &[catalyst_signed_doc::IdUri], -) -> anyhow::Result { - let original_doc = match FullSignedDoc::retrieve(&doc_id.uuid(), None).await { - Ok(doc) => Some(doc), - Err(e) if e.is::() => None, +/// 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}"), }; - if let Some(original_doc) = original_doc { - if original_doc.id() != &doc_id.uuid() || original_doc.ver() != &doc_ver.uuid() { - let Ok(original_authors) = original_doc - .body() - .authors() - .iter() - .map(|author| catalyst_signed_doc::IdUri::from_str(author)) - .collect::, _>>() - else { - anyhow::bail!("Parsing db document error") - }; - - let result = original_authors.iter().all(|original_author| { - let found = kids.iter().find(|incoming_author| { - incoming_author.as_short_id() == original_author.as_short_id() - }); - - found.is_some_and(|incoming_author| { - incoming_author.role_and_rotation().0 == original_author.role_and_rotation().0 - }) - }); - - return Ok(result); - } - } - Ok(true) + 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. 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 251f84052454..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,46 +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}" -@pytest.mark.skip("Enable when it will ready run Cardano indexing in CI") -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}" @@ -227,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}" @@ -235,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}" @@ -244,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}" @@ -252,37 +297,55 @@ def test_proposal_doc(proposal_doc_factory, rbac_auth_token_factory): logger.info("Proposal document test successful.") -@pytest.mark.skip("Enable when it will ready run Cardano indexing in CI") -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}" @@ -290,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}" @@ -298,36 +364,59 @@ def test_comment_doc(comment_doc_factory, rbac_auth_token_factory): logger.info("Comment document test successful.") -@pytest.mark.skip("Enable when it will ready run Cardano indexing in CI") -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}" @@ -335,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}" @@ -343,9 +435,10 @@ def test_submission_action(submission_action_factory, rbac_auth_token_factory): logger.info("Submission action document test successful.") -@pytest.mark.skip("Enable when it will ready run Cardano indexing in CI") -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() @@ -353,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}" @@ -362,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 @@ -375,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 @@ -399,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