From fd5fe2b5aced8c72a63d37d1f04ae4eb9aa3f188 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Wed, 22 Oct 2025 14:33:20 +0700 Subject: [PATCH 01/24] define new event wit for auth Signed-off-by: bkioshn --- wasm/wasi/wit/deps/http-gateway/api.wit | 8 +++++++- wasm/wasi/wit/deps/http-gateway/event.wit | 12 +++++++++++- wasm/wasi/wit/deps/http-gateway/world.wit | 3 ++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/wasm/wasi/wit/deps/http-gateway/api.wit b/wasm/wasi/wit/deps/http-gateway/api.wit index a3a8beb278..d4551e3429 100644 --- a/wasm/wasi/wit/deps/http-gateway/api.wit +++ b/wasm/wasi/wit/deps/http-gateway/api.wit @@ -6,7 +6,7 @@ interface api { type header = tuple>; type headers = list
; - record http-response{ + record http-response { code: u16, headers: headers, body: bstr @@ -16,4 +16,10 @@ interface api { http(http-response), internal-redirect(string), } + + /// Authentication request + record auth-request { + /// Need only the header + headers: headers, + } } \ No newline at end of file diff --git a/wasm/wasi/wit/deps/http-gateway/event.wit b/wasm/wasi/wit/deps/http-gateway/event.wit index 617a66a2a4..e88519f56f 100644 --- a/wasm/wasi/wit/deps/http-gateway/event.wit +++ b/wasm/wasi/wit/deps/http-gateway/event.wit @@ -5,10 +5,20 @@ /// This API is ALWAYS available. -/// Logging API Interface +/// HTTP gateway event API Interface interface event { use hermes:binary/api.{bstr}; use api.{headers, http-gateway-response}; reply: func(body: bstr, headers: headers, path: string, method: string) -> option; + +} + +/// Authentication event API Interface +interface event-auth { + use api.{auth-request, http-response}; + /// Validate authentication for an HTTP request. + /// If there authentication is required, this event is called before routing to target modules. + validate-auth: func(request: auth-request) -> option; + } \ No newline at end of file diff --git a/wasm/wasi/wit/deps/http-gateway/world.wit b/wasm/wasi/wit/deps/http-gateway/world.wit index b5eb17afcb..5d0881e418 100644 --- a/wasm/wasi/wit/deps/http-gateway/world.wit +++ b/wasm/wasi/wit/deps/http-gateway/world.wit @@ -2,6 +2,7 @@ package hermes:http-gateway; world all { import api; - + export event; + export event-auth; } From 7d6cabad100f8ee3b6f73fceb670a9fbcf15f447 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Wed, 22 Oct 2025 14:34:05 +0700 Subject: [PATCH 02/24] setup auth module Signed-off-by: bkioshn --- hermes/apps/athena/manifest_app.json | 6 ++++- hermes/apps/athena/modules/auth/Cargo.toml | 22 ++++++++++++++++ hermes/apps/athena/modules/auth/Earthfile | 22 ++++++++++++++++ hermes/apps/athena/modules/auth/blueprint.cue | 0 .../apps/athena/modules/auth/lib/config.json | 1 + .../modules/auth/lib/config.schema.json | 1 + .../modules/auth/lib/manifest_module.json | 13 ++++++++++ .../athena/modules/auth/lib/metadata.json | 26 +++++++++++++++++++ .../modules/auth/lib/settings.schema.json | 1 + justfile | 2 ++ 10 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 hermes/apps/athena/modules/auth/Cargo.toml create mode 100644 hermes/apps/athena/modules/auth/Earthfile create mode 100644 hermes/apps/athena/modules/auth/blueprint.cue create mode 100644 hermes/apps/athena/modules/auth/lib/config.json create mode 100644 hermes/apps/athena/modules/auth/lib/config.schema.json create mode 100644 hermes/apps/athena/modules/auth/lib/manifest_module.json create mode 100644 hermes/apps/athena/modules/auth/lib/metadata.json create mode 100644 hermes/apps/athena/modules/auth/lib/settings.schema.json diff --git a/hermes/apps/athena/manifest_app.json b/hermes/apps/athena/manifest_app.json index d1d8922488..0bd7306587 100644 --- a/hermes/apps/athena/manifest_app.json +++ b/hermes/apps/athena/manifest_app.json @@ -15,7 +15,11 @@ { "package": "modules/rbac-registration/lib/rbac_registration.hmod", "name": "rbac_registration" + }, + { + "package": "modules/auth/lib/auth.hmod", + "name": "auth" } ], "www": "modules/http-proxy/lib/www" -} \ No newline at end of file +} diff --git a/hermes/apps/athena/modules/auth/Cargo.toml b/hermes/apps/athena/modules/auth/Cargo.toml new file mode 100644 index 0000000000..62330f386f --- /dev/null +++ b/hermes/apps/athena/modules/auth/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "auth" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +shared = { version = "0.1.0", path = "../../shared", features = ["cardano-blockchain-types"] } +anyhow = "1.0.98" +ed25519-dalek = "2.1.1" +base64 = "0.22.1" +chrono = "0.4.38" +regex = "1.11.1" +thiserror = "1.0.68" + + +cardano-blockchain-types = { version = "0.0.6", git = "https://github.com/input-output-hk/catalyst-libs", tag = "cardano-blockchain-types/v0.0.6" } +rbac-registration = { version = "0.0.10", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "rbac-registration/v0.0.10" } +catalyst-types = { version = "0.0.7", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "catalyst-types/v0.0.7" } +c509-certificate = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "c509-certificate-v0.0.3" } diff --git a/hermes/apps/athena/modules/auth/Earthfile b/hermes/apps/athena/modules/auth/Earthfile new file mode 100644 index 0000000000..98643d3a6e --- /dev/null +++ b/hermes/apps/athena/modules/auth/Earthfile @@ -0,0 +1,22 @@ +VERSION 0.8 + +IMPORT ../../../../ AS hermes +IMPORT ../.. AS athena + +build-auth: + DO athena+BUILD_ATHENA_COMPONENT --out=auth.wasm + +local-build-auth: + FROM scratch + COPY +build-auth/auth.wasm . + SAVE ARTIFACT auth.wasm AS LOCAL lib/auth.wasm + + +auth-package-module: + FROM hermes+build + + COPY lib/ . + COPY +build-auth/auth.wasm . + + RUN ./target/release/hermes module package manifest_module.json + SAVE ARTIFACT auth.hmod AS LOCAL lib/auth.hmod diff --git a/hermes/apps/athena/modules/auth/blueprint.cue b/hermes/apps/athena/modules/auth/blueprint.cue new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hermes/apps/athena/modules/auth/lib/config.json b/hermes/apps/athena/modules/auth/lib/config.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/config.json @@ -0,0 +1 @@ +{} diff --git a/hermes/apps/athena/modules/auth/lib/config.schema.json b/hermes/apps/athena/modules/auth/lib/config.schema.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/config.schema.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/hermes/apps/athena/modules/auth/lib/manifest_module.json b/hermes/apps/athena/modules/auth/lib/manifest_module.json new file mode 100644 index 0000000000..21f35770fe --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/manifest_module.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/input-output-hk/hermes/main/hermes/schemas/hermes_module_manifest.schema.json", + "name": "auth", + "metadata": "metadata.json", + "component": "auth.wasm", + "config": { + "file": "config.json", + "schema": "config.schema.json" + }, + "settings": { + "schema": "settings.schema.json" + } +} diff --git a/hermes/apps/athena/modules/auth/lib/metadata.json b/hermes/apps/athena/modules/auth/lib/metadata.json new file mode 100644 index 0000000000..6e8047c13f --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/metadata.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/input-output-hk/hermes/main/hermes/schemas/hermes_app_metadata.schema.json", + "name": "Authentication service", + "version": "V0.1.0", + "description": "Catalyst RBAC authentication service", + "src": [ + "https://github.com/input-output-hk/hermes", + "https://github.com/input-output-hk/catalyst-voices" + ], + "copyright": ["Copyright Ⓒ 2024, IOG Singapore."], + "license": [ + { + "spdx": "Apache-2.0", + "file": "/srv/data/apache2.txt" + }, + { + "spdx": "MIT", + "file": "/srv/data/mit.txt" + } + ], + "developer": { + "name": "IOG Singapore", + "contact": "redirect-service@iohk.io", + "payment": "wallet address" + } +} diff --git a/hermes/apps/athena/modules/auth/lib/settings.schema.json b/hermes/apps/athena/modules/auth/lib/settings.schema.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/settings.schema.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/justfile b/justfile index 95eb146188..ad0d767e0c 100644 --- a/justfile +++ b/justfile @@ -103,6 +103,7 @@ get-local-athena: earthly ./hermes/apps/athena/modules/http-proxy+local-build-http-proxy earthly ./hermes/apps/athena/modules/rbac-registration-indexer+local-build-rbac-registration-indexer earthly ./hermes/apps/athena/modules/rbac-registration+local-build-rbac-registration + earthly ./hermes/apps/athena/modules/auth+local-build-auth echo "✅ WASM compilation complete" @@ -114,6 +115,7 @@ get-local-athena: target/release/hermes module package hermes/apps/athena/modules/http-proxy/lib/manifest_module.json target/release/hermes module package hermes/apps/athena/modules/rbac-registration-indexer/lib/manifest_module.json target/release/hermes module package hermes/apps/athena/modules/rbac-registration/lib/manifest_module.json + target/release/hermes module package hermes/apps/athena/modules/auth/lib/manifest_module.json echo "✅ Module packaging complete (.hmod file created)" echo "📦 Packaging application bundle..." From 5d1719cb1382bcca7cf33e07c89e2d58ce4191b8 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Wed, 22 Oct 2025 19:07:42 +0700 Subject: [PATCH 03/24] fix integration test Signed-off-by: bkioshn --- wasm/integration-test/golang/main.go | 5 +++++ wasm/stub-module/stub-module.c | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/wasm/integration-test/golang/main.go b/wasm/integration-test/golang/main.go index dc6c6728c2..0e54d7f16b 100644 --- a/wasm/integration-test/golang/main.go +++ b/wasm/integration-test/golang/main.go @@ -5,6 +5,7 @@ import ( cardano_event_on_immutable_roll_forward "hermes-golang-app-test/binding/hermes/cardano/event-on-immutable-roll-forward" cron "hermes-golang-app-test/binding/hermes/cron/event" http_gateway "hermes-golang-app-test/binding/hermes/http-gateway/event" + auth "hermes-golang-app-test/binding/hermes/http-gateway/event-auth" http_request "hermes-golang-app-test/binding/hermes/http-request/event" init_event "hermes-golang-app-test/binding/hermes/init/event" int_test "hermes-golang-app-test/binding/hermes/integration-test/event" @@ -53,6 +54,9 @@ func (t TestModule) Reply(body http_gateway.Bstr, headers http_gateway.Headers, func (t TestModule) OnHTTPResponse(requestID cm.Option[uint64], response cm.List[uint8]) {} +func (t TestModule) ValidateAuth(request auth.AuthRequest) cm.Option[auth.HTTPResponse] { + return cm.None[auth.HTTPResponse]() +} func init() { module := TestModule{} @@ -67,6 +71,7 @@ func init() { kv.Exports.KvUpdate = module.KvUpdate http_gateway.Exports.Reply = module.Reply http_request.Exports.OnHTTPResponse = module.OnHTTPResponse + auth.Exports.ValidateAuth = module.ValidateAuth } func main() {} diff --git a/wasm/stub-module/stub-module.c b/wasm/stub-module/stub-module.c index e90c50fbd4..5f4f02b3e8 100644 --- a/wasm/stub-module/stub-module.c +++ b/wasm/stub-module/stub-module.c @@ -49,3 +49,8 @@ bool exports_hermes_integration_test_event_bench(uint32_t test, bool run, hermes void exports_hermes_http_request_event_on_http_response(uint64_t *maybe_request_id, hermes_list_u8_t *response) { } + +bool exports_hermes_http_gateway_event_auth_validate_auth(exports_hermes_http_gateway_event_auth_auth_request_t *auth_request, exports_hermes_http_gateway_event_auth_http_response_t *ret) { + return false; +} + From e27f36c419a13b5064274cd717639f4f6873911b Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 14:17:58 +0700 Subject: [PATCH 04/24] add auth level Signed-off-by: bkioshn --- wasm/wasi/wit/deps/http-gateway/api.wit | 14 ++++++++++++-- wasm/wasi/wit/deps/http-gateway/event.wit | 7 ++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/wasm/wasi/wit/deps/http-gateway/api.wit b/wasm/wasi/wit/deps/http-gateway/api.wit index d4551e3429..24933cdf37 100644 --- a/wasm/wasi/wit/deps/http-gateway/api.wit +++ b/wasm/wasi/wit/deps/http-gateway/api.wit @@ -17,9 +17,19 @@ interface api { internal-redirect(string), } - /// Authentication request + variant auth-level { + /// Auth is required + required, + /// Auth is optional + optional, + /// Auth is not required + none, + } + record auth-request { - /// Need only the header + /// HTTP request header headers: headers, + /// Auth level + auth-level: auth-level, } } \ No newline at end of file diff --git a/wasm/wasi/wit/deps/http-gateway/event.wit b/wasm/wasi/wit/deps/http-gateway/event.wit index e88519f56f..2402841586 100644 --- a/wasm/wasi/wit/deps/http-gateway/event.wit +++ b/wasm/wasi/wit/deps/http-gateway/event.wit @@ -14,11 +14,12 @@ interface event { } -/// Authentication event API Interface +/// Auth event API Interface interface event-auth { use api.{auth-request, http-response}; - /// Validate authentication for an HTTP request. - /// If there authentication is required, this event is called before routing to target modules. + + /// Validate auth for an HTTP request. + /// This event is called before routing to target modules. validate-auth: func(request: auth-request) -> option; } \ No newline at end of file From 8310fda9d19e604bad37999379165cf7da1db28b Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 14:21:05 +0700 Subject: [PATCH 05/24] implement auth Signed-off-by: bkioshn --- .../hermes/http_gateway/auth/auth_config.rs | 122 ++++++++++++++++++ .../hermes/http_gateway/auth/auth_event.rs | 96 ++++++++++++++ .../hermes/http_gateway/auth/config/README.md | 55 ++++++++ .../hermes/http_gateway/auth/config/auth.json | 10 ++ .../hermes/http_gateway/auth/mod.rs | 4 + .../hermes/http_gateway/gateway_task.rs | 3 + .../hermes/http_gateway/mod.rs | 2 + .../hermes/http_gateway/routing.rs | 70 ++++++---- .../hermes/http_gateway/utils.rs | 36 ++++++ 9 files changed, 375 insertions(+), 23 deletions(-) create mode 100644 hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/auth_config.rs create mode 100644 hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/auth_event.rs create mode 100644 hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/README.md create mode 100644 hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/auth.json create mode 100644 hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/mod.rs create mode 100644 hermes/bin/src/runtime_extensions/hermes/http_gateway/utils.rs diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/auth_config.rs b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/auth_config.rs new file mode 100644 index 0000000000..dd2e11803e --- /dev/null +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/auth_config.rs @@ -0,0 +1,122 @@ +//! Auth configuration + +use std::sync::LazyLock; + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use tracing::error; + +use crate::runtime_extensions::bindings::hermes::http_gateway; + +/// Auth configuration file +const AUTH_CONFIG_FILE: &str = include_str!("config/auth.json"); + +/// Auth configuration +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct AuthConfig { + /// Auth rules + pub auth_rules: Vec, + /// Default auth level, if no rule matches + pub default_auth_level: AuthLevel, +} + +/// Auth rule +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct AuthRule { + /// Path regex + pub path_regex: String, + /// Method + pub method: String, + /// Auth level + pub auth_level: AuthLevel, +} + +/// Auth level +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum AuthLevel { + /// Required auth + Required, + /// Optional auth + Optional, + /// No auth required + None, +} + +/// Load auth configuration from config file. +/// This should not fail, but if it does, it will return a default config. +pub(crate) static AUTH_CONFIG: LazyLock = LazyLock::new(|| { + serde_json::from_str(AUTH_CONFIG_FILE).unwrap_or_else(|e| { + error!(error=%e, "Failed to parse auth config file"); + AuthConfig::default() + }) +}); + +impl Default for AuthConfig { + fn default() -> Self { + Self { + auth_rules: vec![], + default_auth_level: AuthLevel::Required, + } + } +} + +impl AuthConfig { + /// Get auth level for a given method and path. + pub(crate) fn get_auth_level( + &self, + method: &str, + path: &str, + ) -> AuthLevel { + for rule in &self.auth_rules { + if Self::matches_rule(method, path, rule) { + return rule.auth_level.clone(); + } + } + self.default_auth_level.clone() + } + + /// Check if a rule matches a given method and path. + fn matches_rule( + method: &str, + path: &str, + rule: &AuthRule, + ) -> bool { + // Case insensitive method match + let method_matches = rule.method.eq_ignore_ascii_case(method); + + // Regex based path match + let path_matches = match Regex::new(&rule.path_regex) { + Ok(regex) => regex.is_match(path), + Err(e) => { + error!(error=%e,"Invalid regex in auth rule '{}'", rule.path_regex); + false + }, + }; + method_matches && path_matches + } +} + +impl From for http_gateway::api::AuthLevel { + fn from(auth_level: AuthLevel) -> Self { + match auth_level { + AuthLevel::Required => http_gateway::api::AuthLevel::Required, + AuthLevel::Optional => http_gateway::api::AuthLevel::Optional, + AuthLevel::None => http_gateway::api::AuthLevel::None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_config() { + // Ensure that the config loads successfully + assert!( + !AUTH_CONFIG.auth_rules.is_empty(), + "Auth rules should not be empty" + ); + } +} diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/auth_event.rs b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/auth_event.rs new file mode 100644 index 0000000000..45f49ba515 --- /dev/null +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/auth_event.rs @@ -0,0 +1,96 @@ +//! Auth Event + +use std::sync::mpsc::Sender; + +use crate::{ + app::ApplicationName, + event::{HermesEvent, HermesEventPayload, TargetApp, TargetModule}, + runtime_extensions::{ + bindings::{ + hermes::http_gateway::api::{AuthRequest, HttpResponse}, + unchecked_exports, + }, + hermes::http_gateway::auth::auth_config::AuthLevel as RTEAuthLevel, + }, +}; +unchecked_exports::define! { + /// Extends [`wasmtime::component::Instance`] with auth functions + trait ComponentInstanceExt { + #[wit("hermes:http-gateway/event-auth", "validate-auth")] + fn hermes_http_gateway_validate_auth( + request: &AuthRequest, + ) -> Option; + } +} + +/// Auth validation event +pub(crate) struct AuthValidationEvent { + /// Request header. + pub(crate) headers: Vec<(String, Vec)>, + /// Auth level. + pub(crate) auth_level: RTEAuthLevel, + /// Channel to send result. + pub(crate) result_sender: Sender, +} + +impl HermesEventPayload for AuthValidationEvent { + /// Event name + fn event_name(&self) -> &'static str { + "validate-auth" + } + + /// Execute the event + fn execute( + &self, + module: &mut crate::wasm::module::ModuleInstance, + ) -> anyhow::Result<()> { + let auth_request = AuthRequest { + headers: self.headers.clone(), + auth_level: self.auth_level.clone().into(), + }; + + // Get result from auth module + let result = module + .instance + .hermes_http_gateway_validate_auth(&mut module.store, &auth_request)?; + + // Send result back + match result { + Some(r) => self.result_sender.send(r).map_err(Into::into), + None => Ok(()), + } + } +} + +// -------- Event Builder ---------- + +/// Build and send auth validation event +pub(crate) fn build_and_send_auth_event( + app_name: &ApplicationName, + headers: Vec<(String, Vec)>, + auth_level: RTEAuthLevel, + result_sender: Sender, +) -> anyhow::Result<()> { + /// Auth module name + const AUTH_MODULE_NAME: &str = "auth"; + + // Get the module ID from the name + let app = crate::reactor::get_app(app_name)?; + let modules = app.get_module_registry(); + let module_id = modules + .get(AUTH_MODULE_NAME) + .ok_or_else(|| anyhow::anyhow!("Module {AUTH_MODULE_NAME} not found"))? + .clone(); + + let auth_event = AuthValidationEvent { + headers, + auth_level, + result_sender, + }; + // Send the event + crate::event::queue::send(HermesEvent::new( + auth_event, + TargetApp::List(vec![app_name.clone()]), + TargetModule::List(vec![module_id]), + )) +} diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/README.md b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/README.md new file mode 100644 index 0000000000..331a63128f --- /dev/null +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/README.md @@ -0,0 +1,55 @@ +# Authentication and Authorization Configuration Guide + +This guide explains how to configure auth. + +## Overview + +`auth.json` provides rules for each specific path that match the regular expression and a method. +The level of authentication and authorization depends on the `auth_level` where it can be: + +```rust +// Auth levels for specific routes +pub enum AuthLevel { + Required, // Authentication is mandatory + Optional, // Authentication is optional (validates if present) + None, // No authentication required +} +``` + +If the rule for a specific path is not defined, the default level will be applied. + + +## Configuration File Format + +The `auth.json` file defines auth rules using the following structure: + +```json +{ + "auth_rules": [ + { + "path_regex": "^/api/v1/registration(/.*)?$", + "method": "GET", + "auth_level": "optional" + }, + { + "path_regex": "^/api/v1/document$", + "method": "PUT", + "auth_level": "required" + } + ], + "default_auth_level": "none" +} + +### Configuration Fields + +- **`auth_rules`**: Array of auth rules for specific paths +- **`path_regex`**: Regular expression pattern to match request paths +- **`method`**: HTTP method (GET, POST, PUT, DELETE, etc.) +- **`auth_level`**: Authentication level for matching requests +- **`default_auth_level`**: Default authentication level for unmatched paths + +### Auth Level Values + +- **`"required"`**: Authentication is mandatory - requests without valid tokens are rejected +- **`"optional"`**: Authentication is optional - tokens are validated if present, but requests without tokens are allowed +- **`"none"`**: No authentication required - all requests are allowed regardless of token presence diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/auth.json b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/auth.json new file mode 100644 index 0000000000..29d2018725 --- /dev/null +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/auth.json @@ -0,0 +1,10 @@ +{ + "auth_rules": [ + { + "path_regex": "^/api/v1/registration(/.*)?$", + "method": "GET", + "auth_level": "optional" + } + ], + "default_auth_level": "none" +} diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/mod.rs b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/mod.rs new file mode 100644 index 0000000000..b1790de5ed --- /dev/null +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/mod.rs @@ -0,0 +1,4 @@ +//! Auth module + +pub(crate) mod auth_config; +pub(crate) mod auth_event; diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/gateway_task.rs b/hermes/bin/src/runtime_extensions/hermes/http_gateway/gateway_task.rs index f59ab4aa22..cff6777178 100644 --- a/hermes/bin/src/runtime_extensions/hermes/http_gateway/gateway_task.rs +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/gateway_task.rs @@ -30,6 +30,8 @@ pub(crate) struct Config { pub(crate) valid_hosts: Vec, /// Local address for boot strap pub(crate) local_addr: SocketAddr, + /// Whether auth is activated + pub(crate) is_auth_activate: bool, } /// We will eventually use env vars when deployment pipeline is in place, hardcoded @@ -43,6 +45,7 @@ impl Default for Config { ] .to_vec(), local_addr: SocketAddr::new([127, 0, 0, 1].into(), GATEWAY_PORT), + is_auth_activate: true, } } } diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/mod.rs b/hermes/bin/src/runtime_extensions/hermes/http_gateway/mod.rs index d90ee1496b..8b28a645ff 100644 --- a/hermes/bin/src/runtime_extensions/hermes/http_gateway/mod.rs +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/mod.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use subscription::{register_global_endpoint_subscription, EndpointSubscription}; use tracing::{error, info}; +mod auth; mod event; mod gateway_task; mod host; @@ -12,6 +13,7 @@ mod host; mod routing; /// Subscription management for targeted routing mod subscription; +mod utils; /// endpoint sub #[derive(Debug, Deserialize)] diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/routing.rs b/hermes/bin/src/runtime_extensions/hermes/http_gateway/routing.rs index f2a09ca326..3091c37fc1 100644 --- a/hermes/bin/src/runtime_extensions/hermes/http_gateway/routing.rs +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/routing.rs @@ -27,7 +27,11 @@ use crate::{ app::{Application, ApplicationName}, event::{HermesEvent, TargetApp, TargetModule}, reactor, - runtime_extensions::hermes::http_gateway::subscription::find_global_endpoint_subscription, + runtime_extensions::hermes::http_gateway::{ + auth::{auth_config::AUTH_CONFIG, auth_event}, + subscription::find_global_endpoint_subscription, + utils::{build_http_response, extract_headers_kv}, + }, }; /// Everything that hits /api routes to Webasm Component Modules @@ -94,7 +98,7 @@ pub(crate) async fn router( { // If hostname is valid, route the request to the Hermes WASM runtime // The app_name determines which specific application will handle it - route_to_hermes(req, app_name.clone()).await? + route_to_hermes(req, app_name.clone(), config.is_auth_activate).await? } else { // If hostname is not in the valid list, reject with an error response // This prevents potential security issues from unauthorized hosts @@ -162,6 +166,7 @@ pub(crate) fn host_resolver(headers: &HeaderMap) -> anyhow::Result<(ApplicationN async fn route_to_hermes( req: Request, app_name: ApplicationName, + is_auth_activate: bool, ) -> anyhow::Result>> { // Extract the URI for route analysis - this contains path and query parameters let uri = req.uri().to_owned(); @@ -174,9 +179,18 @@ async fn route_to_hermes( // API endpoints need WebAssembly module processing // These requests go through the event queue to WASM components RouteType::WebAssembly(path, module_id) => { + if is_auth_activate { + let auth_response = handle_auth_for_request(&req, &app_name)?; + + // If auth somehow failed, return the failure + // If not, move to wasm module routing + if auth_response.status() != StatusCode::OK { + return Ok(auth_response); + } + } handle_webasm_request(req, path, module_id, app_name).await - // Static files are served directly from the virtual file system }, + // Static files are served directly from the virtual file system RouteType::StaticFile(path) => serve_static_web_content(&path, &app_name), } } @@ -261,16 +275,7 @@ async fn handle_webasm_request( ) -> anyhow::Result>> { let (lambda_send, lambda_recv_answer) = channel(); let method = req.method().to_string(); - - let headers: HeadersKV = req - .headers() - .iter() - .map(|(name, value)| { - let key = name.to_string(); - let values = vec![value.to_str().unwrap_or_default().to_string()]; - (key, values) - }) - .collect(); + let headers = extract_headers_kv(req.headers()); let (_parts, body) = req.into_parts(); let body_bytes = body.collect().await?.to_bytes(); @@ -286,6 +291,34 @@ async fn handle_webasm_request( compose_http_event(request_params, &lambda_recv_answer, module_id, &app_name) } +/// Handle authentication/authorization part for the request. +/// If auth is required, this function will send the request to auth module and wait for +/// response. If it is success, the function will return HTTP response with a 200 +/// response. If it is not success, the function will return a corresponding error. +fn handle_auth_for_request( + req: &Request, + app_name: &ApplicationName, +) -> anyhow::Result>> { + let headers = extract_headers_kv(req.headers()); + let method = req.method().as_str(); + let path = req.uri().path(); + + let auth_level = AUTH_CONFIG.get_auth_level(method, path); + + let (result_sender, result_receiver) = channel(); + if let Err(e) = + auth_event::build_and_send_auth_event(app_name, headers, auth_level, result_sender) + { + return error_response(format!("Failed to send auth event {e}")); + } + // Wait for auth response + if let Ok(r) = result_receiver.recv_timeout(Duration::from_secs(EVENT_TIMEOUT)) { + build_http_response(r.code, r.headers, r.body) + } else { + error_response("Authentication timeout") + } +} + /// HTTP request parameters for WebAssembly event processing struct HttpRequestParams { /// HTTP method (GET, POST, etc.) @@ -386,16 +419,7 @@ where let timeout = Duration::from_secs(EVENT_TIMEOUT); match receiver.recv_timeout(timeout)? { HTTPEventMsg::HttpEventResponse((status_code, headers, body)) => { - let mut response_builder = Response::builder().status(status_code); - - // Add headers to response - for (key, values) in headers { - for value in values { - response_builder = response_builder.header(&key, value); - } - } - let response = response_builder.body(body.into())?; - Ok(response) + build_http_response(status_code, headers, body) }, HTTPEventMsg::HTTPEventReceiver => error_response("Invalid HTTP event message received"), } diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/utils.rs b/hermes/bin/src/runtime_extensions/hermes/http_gateway/utils.rs new file mode 100644 index 0000000000..a4e53b2b1a --- /dev/null +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/utils.rs @@ -0,0 +1,36 @@ +//! Utility functions for HTTP gateway + +use hyper::{body::Body, HeaderMap, Response}; + +use crate::runtime_extensions::hermes::http_gateway::event::HeadersKV; + +/// Extract headers from request into `HeadersKV` format. +pub(crate) fn extract_headers_kv(headers: &HeaderMap) -> HeadersKV { + headers + .iter() + .map(|(name, value)| { + (name.to_string(), vec![value + .to_str() + .unwrap_or_default() + .to_string()]) + }) + .collect() +} + +/// Build HTTP response from status code, headers, and body +pub(crate) fn build_http_response( + status_code: u16, + headers: Vec<(String, Vec)>, + body: Vec, +) -> anyhow::Result> +where + B: Body + From>, +{ + let mut response_builder = Response::builder().status(status_code); + for (key, values) in headers { + for value in values { + response_builder = response_builder.header(&key, value); + } + } + Ok(response_builder.body(body.into())?) +} From 06d46d20375dcdd5eed50bee8d14a14fa628e74a Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 14:23:13 +0700 Subject: [PATCH 06/24] fix typo Signed-off-by: bkioshn --- hermes/apps/athena/modules/auth/lib/metadata.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hermes/apps/athena/modules/auth/lib/metadata.json b/hermes/apps/athena/modules/auth/lib/metadata.json index 6e8047c13f..eb7f41b85d 100644 --- a/hermes/apps/athena/modules/auth/lib/metadata.json +++ b/hermes/apps/athena/modules/auth/lib/metadata.json @@ -1,8 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/input-output-hk/hermes/main/hermes/schemas/hermes_app_metadata.schema.json", - "name": "Authentication service", + "name": "Authentication and Authorization service", "version": "V0.1.0", - "description": "Catalyst RBAC authentication service", + "description": "Catalyst RBAC authentication and authorization service", "src": [ "https://github.com/input-output-hk/hermes", "https://github.com/input-output-hk/catalyst-voices" From 4074d8f4d835a1025fe2fe330f1d4d24cebe2deb Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 15:32:33 +0700 Subject: [PATCH 07/24] fix integration test Signed-off-by: bkioshn --- .../runtime_extensions/hermes/http_gateway/auth/config/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/README.md b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/README.md index 331a63128f..c32f9b1386 100644 --- a/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/README.md +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/auth/config/README.md @@ -18,7 +18,6 @@ pub enum AuthLevel { If the rule for a specific path is not defined, the default level will be applied. - ## Configuration File Format The `auth.json` file defines auth rules using the following structure: From d0003ef67ede1593a2054fe6f54a1afc5bdc59b6 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 16:10:20 +0700 Subject: [PATCH 08/24] remove auth module Signed-off-by: bkioshn --- hermes/apps/athena/manifest_app.json | 4 --- hermes/apps/athena/modules/auth/Cargo.toml | 22 ---------------- hermes/apps/athena/modules/auth/Earthfile | 22 ---------------- hermes/apps/athena/modules/auth/blueprint.cue | 0 .../apps/athena/modules/auth/lib/config.json | 1 - .../modules/auth/lib/config.schema.json | 1 - .../modules/auth/lib/manifest_module.json | 13 ---------- .../athena/modules/auth/lib/metadata.json | 26 ------------------- .../modules/auth/lib/settings.schema.json | 1 - justfile | 2 -- 10 files changed, 92 deletions(-) delete mode 100644 hermes/apps/athena/modules/auth/Cargo.toml delete mode 100644 hermes/apps/athena/modules/auth/Earthfile delete mode 100644 hermes/apps/athena/modules/auth/blueprint.cue delete mode 100644 hermes/apps/athena/modules/auth/lib/config.json delete mode 100644 hermes/apps/athena/modules/auth/lib/config.schema.json delete mode 100644 hermes/apps/athena/modules/auth/lib/manifest_module.json delete mode 100644 hermes/apps/athena/modules/auth/lib/metadata.json delete mode 100644 hermes/apps/athena/modules/auth/lib/settings.schema.json diff --git a/hermes/apps/athena/manifest_app.json b/hermes/apps/athena/manifest_app.json index 0bd7306587..5e9dd924c1 100644 --- a/hermes/apps/athena/manifest_app.json +++ b/hermes/apps/athena/manifest_app.json @@ -15,10 +15,6 @@ { "package": "modules/rbac-registration/lib/rbac_registration.hmod", "name": "rbac_registration" - }, - { - "package": "modules/auth/lib/auth.hmod", - "name": "auth" } ], "www": "modules/http-proxy/lib/www" diff --git a/hermes/apps/athena/modules/auth/Cargo.toml b/hermes/apps/athena/modules/auth/Cargo.toml deleted file mode 100644 index 62330f386f..0000000000 --- a/hermes/apps/athena/modules/auth/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "auth" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -shared = { version = "0.1.0", path = "../../shared", features = ["cardano-blockchain-types"] } -anyhow = "1.0.98" -ed25519-dalek = "2.1.1" -base64 = "0.22.1" -chrono = "0.4.38" -regex = "1.11.1" -thiserror = "1.0.68" - - -cardano-blockchain-types = { version = "0.0.6", git = "https://github.com/input-output-hk/catalyst-libs", tag = "cardano-blockchain-types/v0.0.6" } -rbac-registration = { version = "0.0.10", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "rbac-registration/v0.0.10" } -catalyst-types = { version = "0.0.7", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "catalyst-types/v0.0.7" } -c509-certificate = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "c509-certificate-v0.0.3" } diff --git a/hermes/apps/athena/modules/auth/Earthfile b/hermes/apps/athena/modules/auth/Earthfile deleted file mode 100644 index 98643d3a6e..0000000000 --- a/hermes/apps/athena/modules/auth/Earthfile +++ /dev/null @@ -1,22 +0,0 @@ -VERSION 0.8 - -IMPORT ../../../../ AS hermes -IMPORT ../.. AS athena - -build-auth: - DO athena+BUILD_ATHENA_COMPONENT --out=auth.wasm - -local-build-auth: - FROM scratch - COPY +build-auth/auth.wasm . - SAVE ARTIFACT auth.wasm AS LOCAL lib/auth.wasm - - -auth-package-module: - FROM hermes+build - - COPY lib/ . - COPY +build-auth/auth.wasm . - - RUN ./target/release/hermes module package manifest_module.json - SAVE ARTIFACT auth.hmod AS LOCAL lib/auth.hmod diff --git a/hermes/apps/athena/modules/auth/blueprint.cue b/hermes/apps/athena/modules/auth/blueprint.cue deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/hermes/apps/athena/modules/auth/lib/config.json b/hermes/apps/athena/modules/auth/lib/config.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/hermes/apps/athena/modules/auth/lib/config.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/hermes/apps/athena/modules/auth/lib/config.schema.json b/hermes/apps/athena/modules/auth/lib/config.schema.json deleted file mode 100644 index 9e26dfeeb6..0000000000 --- a/hermes/apps/athena/modules/auth/lib/config.schema.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/hermes/apps/athena/modules/auth/lib/manifest_module.json b/hermes/apps/athena/modules/auth/lib/manifest_module.json deleted file mode 100644 index 21f35770fe..0000000000 --- a/hermes/apps/athena/modules/auth/lib/manifest_module.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/input-output-hk/hermes/main/hermes/schemas/hermes_module_manifest.schema.json", - "name": "auth", - "metadata": "metadata.json", - "component": "auth.wasm", - "config": { - "file": "config.json", - "schema": "config.schema.json" - }, - "settings": { - "schema": "settings.schema.json" - } -} diff --git a/hermes/apps/athena/modules/auth/lib/metadata.json b/hermes/apps/athena/modules/auth/lib/metadata.json deleted file mode 100644 index eb7f41b85d..0000000000 --- a/hermes/apps/athena/modules/auth/lib/metadata.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/input-output-hk/hermes/main/hermes/schemas/hermes_app_metadata.schema.json", - "name": "Authentication and Authorization service", - "version": "V0.1.0", - "description": "Catalyst RBAC authentication and authorization service", - "src": [ - "https://github.com/input-output-hk/hermes", - "https://github.com/input-output-hk/catalyst-voices" - ], - "copyright": ["Copyright Ⓒ 2024, IOG Singapore."], - "license": [ - { - "spdx": "Apache-2.0", - "file": "/srv/data/apache2.txt" - }, - { - "spdx": "MIT", - "file": "/srv/data/mit.txt" - } - ], - "developer": { - "name": "IOG Singapore", - "contact": "redirect-service@iohk.io", - "payment": "wallet address" - } -} diff --git a/hermes/apps/athena/modules/auth/lib/settings.schema.json b/hermes/apps/athena/modules/auth/lib/settings.schema.json deleted file mode 100644 index 9e26dfeeb6..0000000000 --- a/hermes/apps/athena/modules/auth/lib/settings.schema.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/justfile b/justfile index ad0d767e0c..95eb146188 100644 --- a/justfile +++ b/justfile @@ -103,7 +103,6 @@ get-local-athena: earthly ./hermes/apps/athena/modules/http-proxy+local-build-http-proxy earthly ./hermes/apps/athena/modules/rbac-registration-indexer+local-build-rbac-registration-indexer earthly ./hermes/apps/athena/modules/rbac-registration+local-build-rbac-registration - earthly ./hermes/apps/athena/modules/auth+local-build-auth echo "✅ WASM compilation complete" @@ -115,7 +114,6 @@ get-local-athena: target/release/hermes module package hermes/apps/athena/modules/http-proxy/lib/manifest_module.json target/release/hermes module package hermes/apps/athena/modules/rbac-registration-indexer/lib/manifest_module.json target/release/hermes module package hermes/apps/athena/modules/rbac-registration/lib/manifest_module.json - target/release/hermes module package hermes/apps/athena/modules/auth/lib/manifest_module.json echo "✅ Module packaging complete (.hmod file created)" echo "📦 Packaging application bundle..." From c9d2edd283a2bf96b383acb77bc41c3a11978f68 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 16:12:34 +0700 Subject: [PATCH 09/24] revert Signed-off-by: bkioshn --- hermes/apps/athena/manifest_app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes/apps/athena/manifest_app.json b/hermes/apps/athena/manifest_app.json index 5e9dd924c1..d1d8922488 100644 --- a/hermes/apps/athena/manifest_app.json +++ b/hermes/apps/athena/manifest_app.json @@ -18,4 +18,4 @@ } ], "www": "modules/http-proxy/lib/www" -} +} \ No newline at end of file From e78bf2195a5c6e6ec84610d7dc850dae0fe68cac Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 18:15:44 +0700 Subject: [PATCH 10/24] setup auth module Signed-off-by: bkioshn --- hermes/apps/athena/manifest_app.json | 6 ++++- hermes/apps/athena/modules/auth/Cargo.toml | 26 +++++++++++++++++++ hermes/apps/athena/modules/auth/Earthfile | 22 ++++++++++++++++ hermes/apps/athena/modules/auth/blueprint.cue | 0 .../apps/athena/modules/auth/lib/config.json | 1 + .../modules/auth/lib/config.schema.json | 1 + .../modules/auth/lib/manifest_module.json | 13 ++++++++++ .../athena/modules/auth/lib/metadata.json | 26 +++++++++++++++++++ .../modules/auth/lib/settings.schema.json | 1 + 9 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 hermes/apps/athena/modules/auth/Cargo.toml create mode 100644 hermes/apps/athena/modules/auth/Earthfile create mode 100644 hermes/apps/athena/modules/auth/blueprint.cue create mode 100644 hermes/apps/athena/modules/auth/lib/config.json create mode 100644 hermes/apps/athena/modules/auth/lib/config.schema.json create mode 100644 hermes/apps/athena/modules/auth/lib/manifest_module.json create mode 100644 hermes/apps/athena/modules/auth/lib/metadata.json create mode 100644 hermes/apps/athena/modules/auth/lib/settings.schema.json diff --git a/hermes/apps/athena/manifest_app.json b/hermes/apps/athena/manifest_app.json index d1d8922488..0bd7306587 100644 --- a/hermes/apps/athena/manifest_app.json +++ b/hermes/apps/athena/manifest_app.json @@ -15,7 +15,11 @@ { "package": "modules/rbac-registration/lib/rbac_registration.hmod", "name": "rbac_registration" + }, + { + "package": "modules/auth/lib/auth.hmod", + "name": "auth" } ], "www": "modules/http-proxy/lib/www" -} \ No newline at end of file +} diff --git a/hermes/apps/athena/modules/auth/Cargo.toml b/hermes/apps/athena/modules/auth/Cargo.toml new file mode 100644 index 0000000000..9e51b477f5 --- /dev/null +++ b/hermes/apps/athena/modules/auth/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "auth" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +shared = { version = "0.1.0", path = "../../shared", features = ["cardano-blockchain-types"] } +anyhow = "1.0.98" +ed25519-dalek = "2.1.1" +base64 = "0.22.1" +chrono = "0.4.38" +regex = "1.11.1" +thiserror = "1.0.68" +rand = "0.8.5" +serde_json = "1.0.142" +serde = "1.0.226" + +cardano-blockchain-types = { version = "0.0.6", git = "https://github.com/input-output-hk/catalyst-libs", tag = "cardano-blockchain-types/v0.0.6" } +rbac-registration = { version = "0.0.10", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "rbac-registration/v0.0.10" } +catalyst-types = { version = "0.0.7", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "catalyst-types/v0.0.7" } + +[dev-dependencies] +test-case = "3.3.1" \ No newline at end of file diff --git a/hermes/apps/athena/modules/auth/Earthfile b/hermes/apps/athena/modules/auth/Earthfile new file mode 100644 index 0000000000..4a51c4db5c --- /dev/null +++ b/hermes/apps/athena/modules/auth/Earthfile @@ -0,0 +1,22 @@ +VERSION 0.8 + +IMPORT ../../../../ AS hermes +IMPORT ../.. AS athena + +build-auth: + DO athena+BUILD_ATHENA_COMPONENT --out=auth.wasm + +local-build-auth: + FROM scratch + COPY +build-auth/auth.wasm . + SAVE ARTIFACT auth.wasm AS LOCAL lib/auth.wasm + + +auth-package-module: + FROM hermes+build + + COPY lib/ . + COPY +build-auth/auth.wasm . + + RUN ./target/release/hermes module package manifest_module.json + SAVE ARTIFACT auth.hmod diff --git a/hermes/apps/athena/modules/auth/blueprint.cue b/hermes/apps/athena/modules/auth/blueprint.cue new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hermes/apps/athena/modules/auth/lib/config.json b/hermes/apps/athena/modules/auth/lib/config.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/hermes/apps/athena/modules/auth/lib/config.schema.json b/hermes/apps/athena/modules/auth/lib/config.schema.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/config.schema.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/hermes/apps/athena/modules/auth/lib/manifest_module.json b/hermes/apps/athena/modules/auth/lib/manifest_module.json new file mode 100644 index 0000000000..21f35770fe --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/manifest_module.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/input-output-hk/hermes/main/hermes/schemas/hermes_module_manifest.schema.json", + "name": "auth", + "metadata": "metadata.json", + "component": "auth.wasm", + "config": { + "file": "config.json", + "schema": "config.schema.json" + }, + "settings": { + "schema": "settings.schema.json" + } +} diff --git a/hermes/apps/athena/modules/auth/lib/metadata.json b/hermes/apps/athena/modules/auth/lib/metadata.json new file mode 100644 index 0000000000..eb7f41b85d --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/metadata.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/input-output-hk/hermes/main/hermes/schemas/hermes_app_metadata.schema.json", + "name": "Authentication and Authorization service", + "version": "V0.1.0", + "description": "Catalyst RBAC authentication and authorization service", + "src": [ + "https://github.com/input-output-hk/hermes", + "https://github.com/input-output-hk/catalyst-voices" + ], + "copyright": ["Copyright Ⓒ 2024, IOG Singapore."], + "license": [ + { + "spdx": "Apache-2.0", + "file": "/srv/data/apache2.txt" + }, + { + "spdx": "MIT", + "file": "/srv/data/mit.txt" + } + ], + "developer": { + "name": "IOG Singapore", + "contact": "redirect-service@iohk.io", + "payment": "wallet address" + } +} diff --git a/hermes/apps/athena/modules/auth/lib/settings.schema.json b/hermes/apps/athena/modules/auth/lib/settings.schema.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/hermes/apps/athena/modules/auth/lib/settings.schema.json @@ -0,0 +1 @@ +{} \ No newline at end of file From 857b2b167d9d56c1949d239483f20e409903b705 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 18:33:11 +0700 Subject: [PATCH 11/24] copy rbac related Signed-off-by: bkioshn --- .../athena/modules/auth/src/database/mod.rs | 10 + .../auth/src/database/query_builder.rs | 40 +++ .../auth/src/database/select/cat_id.rs | 238 ++++++++++++++++++ .../modules/auth/src/database/select/mod.rs | 12 + .../modules/auth/src/rbac/build_rbac_chain.rs | 114 +++++++++ .../athena/modules/auth/src/rbac/get_rbac.rs | 30 +++ .../apps/athena/modules/auth/src/rbac/mod.rs | 6 + .../auth/src/rbac/rbac_chain_metadata.rs | 14 ++ .../auth/src/rbac/registration_location.rs | 12 + 9 files changed, 476 insertions(+) create mode 100644 hermes/apps/athena/modules/auth/src/database/mod.rs create mode 100644 hermes/apps/athena/modules/auth/src/database/query_builder.rs create mode 100644 hermes/apps/athena/modules/auth/src/database/select/cat_id.rs create mode 100644 hermes/apps/athena/modules/auth/src/database/select/mod.rs create mode 100644 hermes/apps/athena/modules/auth/src/rbac/build_rbac_chain.rs create mode 100644 hermes/apps/athena/modules/auth/src/rbac/get_rbac.rs create mode 100644 hermes/apps/athena/modules/auth/src/rbac/mod.rs create mode 100644 hermes/apps/athena/modules/auth/src/rbac/rbac_chain_metadata.rs create mode 100644 hermes/apps/athena/modules/auth/src/rbac/registration_location.rs diff --git a/hermes/apps/athena/modules/auth/src/database/mod.rs b/hermes/apps/athena/modules/auth/src/database/mod.rs new file mode 100644 index 0000000000..5e78155854 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/database/mod.rs @@ -0,0 +1,10 @@ +//! Database access layer for RBAC registration. +// TODO - This is redundant to what is in rbac-registration module, need to move the share + +pub(crate) mod query_builder; +pub(crate) mod select; + +/// RBAC registration persistent table name. +pub(crate) const RBAC_REGISTRATION_PERSISTENT_TABLE_NAME: &str = "rbac_registration_persistent"; +/// RBAC registration volatile table name. +pub(crate) const RBAC_REGISTRATION_VOLATILE_TABLE_NAME: &str = "rbac_registration_volatile"; diff --git a/hermes/apps/athena/modules/auth/src/database/query_builder.rs b/hermes/apps/athena/modules/auth/src/database/query_builder.rs new file mode 100644 index 0000000000..ad5b480f7c --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/database/query_builder.rs @@ -0,0 +1,40 @@ +//! SQLite query builders + +pub(crate) struct QueryBuilder; + +impl QueryBuilder { + /// Select a root registration from a catalyst ID. + /// The earliest (lowest `slot_no`, then lowest `txn_idx`) registration + /// is considered the canonical/valid registration if multiple exist. + pub(crate) fn select_root_reg_by_cat_id(table: &str) -> String { + format!( + r#" + SELECT txn_id, slot_no, txn_idx + FROM {table} + WHERE prv_txn_id IS NULL + AND problem_report IS NULL + AND catalyst_id = ? + ORDER BY slot_no ASC, txn_idx ASC + LIMIT 1; + "# + ) + } + + /// Select a child registration from a parent registration + /// The earliest (lowest `slot_no`, then lowest `txn_idx`) registration + /// is considered the canonical/valid registration if multiple exist. + /// + /// The child is linked to the parent by the `prv_txn_id` field. + pub(crate) fn select_child_reg_from_parent(table: &str) -> String { + format!( + r#" + SELECT txn_id, slot_no, txn_idx + FROM {table} + WHERE prv_txn_id = ? + AND problem_report IS NULL + ORDER BY slot_no ASC, txn_idx ASC + LIMIT 1; + "# + ) + } +} diff --git a/hermes/apps/athena/modules/auth/src/database/select/cat_id.rs b/hermes/apps/athena/modules/auth/src/database/select/cat_id.rs new file mode 100644 index 0000000000..b022fc333c --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/database/select/cat_id.rs @@ -0,0 +1,238 @@ +//! Select from Catalyst ID. + +use shared::{ + bindings::hermes::sqlite::api::{Sqlite, Statement, StepResult, Value}, + sqlite_bind_parameters, + utils::{ + log::log_error, + sqlite::{ + operation::Operation, + statement::{column_as, DatabaseStatement}, + }, + }, +}; + +use crate::{ + database::{ + query_builder::QueryBuilder, select::TableSource, RBAC_REGISTRATION_PERSISTENT_TABLE_NAME, + RBAC_REGISTRATION_VOLATILE_TABLE_NAME, + }, + rbac::{rbac_chain_metadata::RbacChainMetadata, registration_location::RegistrationLocation}, +}; + +/// Registration chain from a catalyst ID. +/// +/// Selects a root registration and all its children, returning the full chain given a +/// catalyst ID. If no root registration is found for the given `cat_id`, returns an empty +/// list. +/// +/// Root registration: +/// +/// The root registration is the registration with matching `catalyst_id` where +/// no `prv_txn_id`, no `problem_report` - valid, least `slot_no`, and least `txn_idx`. +/// In other words, the earliest valid registration containing the given `catalyst_id` is +/// considered the root. Note that the Catalyst ID is derive from the subject public key +/// or Role 0 registration. If the registration chain contains multiple Catalyst IDs +/// (multiple Role 0 subject public keys), the first catalyst ID in the chain is used. +/// +/// For example, a chain with the first registration having `catalyst_id_a` and the second +/// registration having `catalyst_id_b`, When requesting for `catalyst_id_b` WILL NOT +/// result in the same chain as requesting for `catalyst_id_a`. +/// +/// In addition, if there is an attempt to creating a new chain with `catalyst_id_b`, the +/// chain will be invalid since **IT IS NOT ALLOWED TO USE THE PUBLIC KEYS OF AN EXISTING +/// VALID CHAIN**. +/// +/// +/// Child registration: +/// +/// The child registration is determined by having a `prv_txn_id` pointing back to a +/// parent. In other words, the child registration is the registration with matching +/// `prv_txn_id` where no `problem_report` - valid, least `slot_no`, and least `txn_idx`. +/// +/// An update that causes the link to break is considered invalid. +/// +/// For example, +/// +/// Root : `txn_id` = `a` | `prv_txn_id` = null | slot 10 | valid | +/// Child1 : `txn_id` = `b` | `prv_txn_id` = `a` | slot 11 | valid | +/// Child2 : `txn_id` = `c` | `prv_txn_id` = `b` | slot 12 | invalid | +/// Child3 : `txn_id` = `d` | `prv_txn_id` = `c` | slot 13 | valid | +/// Child4 : `txn_id` = `e` | `prv_txn_id` = `b` | slot 14 | invalid | +/// +/// The valid chain will be Root -> Child1 -> Child4 +/// +/// # Returns +/// +/// * `Ok(Vec, RbacChainMetadata))` – The registration chain related +/// data associated with the given catalyst ID. If the vector is empty, no chain is +/// found. +/// * `Err(anyhow::Error)` – If any error occurs. +pub(crate) fn select_rbac_registration_chain_from_cat_id( + persistent: &Sqlite, + volatile: &Sqlite, + cat_id: &str, +) -> anyhow::Result<(Vec, RbacChainMetadata)> { + const FUNCTION_NAME: &str = "select_rbac_registration_chain_from_cat_id"; + + let mut metadata = RbacChainMetadata::default(); + + // --- Find the root --- + let (mut txn_id, mut chain, root_source) = + match extract_root(persistent, cat_id, RBAC_REGISTRATION_PERSISTENT_TABLE_NAME)?.or_else( + || extract_root(volatile, cat_id, RBAC_REGISTRATION_VOLATILE_TABLE_NAME).ok()?, + ) { + Some(val) => val, + None => return Ok((vec![], metadata)), + }; + + // Update tracking variable based on the root source + match root_source { + TableSource::Persistent => { + metadata.last_persistent_txn = Some(txn_id.clone().try_into()?); + // This should not fail + metadata.last_persistent_slot = chain + .first() + .ok_or_else(|| anyhow::anyhow!("Chain is empty when extracting slot_no"))? + .slot_no + .into(); + }, + TableSource::Volatile => { + metadata.last_volatile_txn = Some(txn_id.clone().try_into()?); + }, + } + + // --- Find children --- + let p_stmt = DatabaseStatement::prepare_statement( + persistent, + &QueryBuilder::select_child_reg_from_parent(RBAC_REGISTRATION_PERSISTENT_TABLE_NAME), + Operation::Select, + FUNCTION_NAME, + )?; + + let v_stmt = DatabaseStatement::prepare_statement( + volatile, + &QueryBuilder::select_child_reg_from_parent(RBAC_REGISTRATION_VOLATILE_TABLE_NAME), + Operation::Select, + FUNCTION_NAME, + )?; + let result: anyhow::Result> = (|| { + loop { + // Persistent first + if let Some((next_txn_id, slot_no, txn_idx)) = + extract_child(&p_stmt, RBAC_REGISTRATION_PERSISTENT_TABLE_NAME, &txn_id)? + { + txn_id = next_txn_id; + chain.push(RegistrationLocation { slot_no, txn_idx }); + + metadata.last_persistent_txn = Some(txn_id.clone().try_into()?); + metadata.last_persistent_slot = slot_no.into(); + continue; + } + + // Then volatile + match extract_child(&v_stmt, RBAC_REGISTRATION_VOLATILE_TABLE_NAME, &txn_id)? { + Some((next_txn_id, slot_no, txn_idx)) => { + txn_id = next_txn_id; + chain.push(RegistrationLocation { slot_no, txn_idx }); + + metadata.last_volatile_txn = Some(txn_id.clone().try_into()?); + }, + None => break, + } + } + Ok(chain) + })(); + + let _ = DatabaseStatement::finalize_statement(p_stmt, FUNCTION_NAME); + let _ = DatabaseStatement::finalize_statement(v_stmt, FUNCTION_NAME); + + result.map(|chain| (chain, metadata)) +} + +/// Extract the root registration. +fn extract_root( + sqlite: &Sqlite, + cat_id: &str, + table: &str, +) -> anyhow::Result, TableSource)>> { + const FUNCTION_NAME: &str = "extract_root"; + + let stmt = DatabaseStatement::prepare_statement( + sqlite, + &QueryBuilder::select_root_reg_by_cat_id(table), + Operation::Select, + FUNCTION_NAME, + )?; + sqlite_bind_parameters!(stmt, FUNCTION_NAME, cat_id.to_string() => "catalyst_id")?; + + // The first valid root registration is chosen + let result = (|| { + match stmt.step() { + Ok(StepResult::Row) => { + let txn_id = stmt.column(0)?; + let slot_no = column_as::(&stmt, 1, FUNCTION_NAME, "slot_no")?; + let txn_idx = column_as::(&stmt, 2, FUNCTION_NAME, "txn_idx")?; + // Should be able to track which table the root came from + let source = if table == RBAC_REGISTRATION_PERSISTENT_TABLE_NAME { + TableSource::Persistent + } else { + TableSource::Volatile + }; + Ok(Some(( + txn_id.clone(), + vec![RegistrationLocation { slot_no, txn_idx }], + source, + ))) + }, + Ok(StepResult::Done) => Ok(None), + Err(e) => { + let error = format!("Failed to step in {table}: {e}"); + log_error( + file!(), + FUNCTION_NAME, + "hermes::sqlite::api::step", + &error, + None, + ); + anyhow::bail!(error); + }, + } + })(); + DatabaseStatement::finalize_statement(stmt, FUNCTION_NAME)?; + result +} + +/// Extract the child registration. +fn extract_child( + stmt: &Statement, + table: &str, + txn_id: &Value, +) -> anyhow::Result> { + const FUNCTION_NAME: &str = "extract_child"; + + // Reset first to ensure the statement is in a clean state + DatabaseStatement::reset_statement(stmt, FUNCTION_NAME)?; + sqlite_bind_parameters!(stmt, FUNCTION_NAME, txn_id.clone() => "txn_id")?; + let result = match stmt.step() { + Ok(StepResult::Row) => { + let next_txn_id = stmt.column(0)?; + let slot_no = column_as::(stmt, 1, FUNCTION_NAME, "slot_no")?; + let txn_idx = column_as::(stmt, 2, FUNCTION_NAME, "txn_idx")?; + Some((next_txn_id, slot_no, txn_idx)) + }, + Ok(StepResult::Done) => None, + Err(e) => { + let error = format!("Failed to step in {table}: {e}"); + log_error( + file!(), + FUNCTION_NAME, + "hermes::sqlite::api::step", + &error, + None, + ); + anyhow::bail!(error); + }, + }; + Ok(result) +} diff --git a/hermes/apps/athena/modules/auth/src/database/select/mod.rs b/hermes/apps/athena/modules/auth/src/database/select/mod.rs new file mode 100644 index 0000000000..f247717cb9 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/database/select/mod.rs @@ -0,0 +1,12 @@ +//! Select from the database. + +pub(crate) mod cat_id; + +/// Enum to track which table the registration came from. +#[derive(Debug, Clone)] +pub enum TableSource { + /// Persistent data. + Persistent, + /// Volatile data. + Volatile, +} diff --git a/hermes/apps/athena/modules/auth/src/rbac/build_rbac_chain.rs b/hermes/apps/athena/modules/auth/src/rbac/build_rbac_chain.rs new file mode 100644 index 0000000000..ca8bf72f32 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/rbac/build_rbac_chain.rs @@ -0,0 +1,114 @@ +//! Build the RBAC registration chain + +use rbac_registration::{cardano::cip509::Cip509, registration::cardano::RegistrationChain}; +use shared::{ + bindings::hermes::cardano::api::{CardanoNetwork, Network}, + utils::{cardano::block::build_block, log::log_error}, +}; + +use crate::rbac::registration_location::RegistrationLocation; + +/// Build the RBAC registration chain. +/// +/// # Arguments +/// +/// * `network` - The network to build the registration chain. +/// * `network_resource` - The network resource used for getting block data. +/// * `registration_location` - The registration chain information. +/// +/// # Return +/// +/// * `Ok(Option)` – A RBAC registration chain or `None` if +/// registration chain is empty. +/// * `Err(anyhow::Error)` - If any error occurs. +pub(crate) fn build_registration_chain( + network: CardanoNetwork, + network_resource: &Network, + registration_location: Vec, +) -> anyhow::Result> { + const FUNCTION_NAME: &str = "build_registration_chain"; + + // The first registration (root) + let first_info = match registration_location.first() { + Some(info) => info, + None => return Ok(None), + }; + + // Root registration use to initialize chain + let root_reg = get_registration( + FUNCTION_NAME, + network, + network_resource, + first_info.slot_no, + first_info.txn_idx, + )?; + let mut reg_chain = RegistrationChain::new(root_reg).ok_or_else(|| { + let error = "Failed to initialize registration chain"; + log_error( + file!(), + FUNCTION_NAME, + "RegistrationChain::new", + error, + None, + ); + anyhow::anyhow!(error) + })?; + + // Append children + for info in registration_location.iter().skip(1) { + let reg = get_registration( + file!(), + network, + network_resource, + info.slot_no, + info.txn_idx, + )?; + if let Some(updated) = reg_chain.update(reg.clone()) { + // If the registration being update is not problematic + // It can be added to the registration chain + if !reg.report().is_problematic() { + reg_chain = updated; + // Broken registration in the chain doesn't break the early created chain, + // there is no need to continue the chain since the data after a broken + // registration should be ignored + } else { + return Ok(Some(reg_chain)); + } + } + } + Ok(Some(reg_chain)) +} + +/// Get a RBAC registration (CIP509) from a block. +fn get_registration( + func_name: &str, + network: CardanoNetwork, + network_resource: &Network, + slot_no: u64, + txn_idx: u16, +) -> anyhow::Result { + let block_resource = network_resource + .get_block(Some(slot_no), 0) + .ok_or_else(|| { + let err = format!("Failed to get block resource at slot {slot_no}"); + log_error(file!(), func_name, "network.get_block", &err, None); + anyhow::anyhow!(err) + })?; + + // Create a multi-era block + let block = build_block(file!(), func_name, network, &block_resource).ok_or_else(|| { + let err = format!("Failed to build block at slot {slot_no}"); + log_error(file!(), func_name, "build_block", &err, None); + anyhow::anyhow!(err) + })?; + + match Cip509::new(&block, txn_idx.into(), &[]) { + Ok(Some(r)) => Ok(r), + // Expect a registration, so treat None as an error + Ok(None) | Err(_) => { + let err = format!("Failed to get registration at slot {slot_no}"); + log_error(file!(), func_name, "Cip509::new", &err, None); + anyhow::bail!(err) + }, + } +} diff --git a/hermes/apps/athena/modules/auth/src/rbac/get_rbac.rs b/hermes/apps/athena/modules/auth/src/rbac/get_rbac.rs new file mode 100644 index 0000000000..3975f01ee1 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/rbac/get_rbac.rs @@ -0,0 +1,30 @@ +//! Get the RBAC chain by Catalyst ID or stake address. + +use catalyst_types::catalyst_id::CatalystId; +use rbac_registration::registration::cardano::RegistrationChain; +use shared::bindings::hermes::{ + cardano::api::{CardanoNetwork, Network}, + sqlite::api::Sqlite, +}; + +use crate::{ + database::select::cat_id::select_rbac_registration_chain_from_cat_id, + rbac::{build_rbac_chain::build_registration_chain, rbac_chain_metadata::RbacChainMetadata}, +}; + +/// Get the RBAC chain by Catalyst ID. +pub(crate) fn get_rbac_chain_from_cat_id( + persistent: &Sqlite, + volatile: &Sqlite, + cat_id: &CatalystId, + network: CardanoNetwork, + network_resource: &Network, +) -> anyhow::Result> { + let (reg_locations, metadata) = + select_rbac_registration_chain_from_cat_id(persistent, volatile, &cat_id.to_string())?; + let reg_chain = build_registration_chain(network, network_resource, reg_locations)?; + if reg_chain.is_none() { + return Ok(None); + } + Ok(reg_chain.map(|chain| (chain, metadata))) +} diff --git a/hermes/apps/athena/modules/auth/src/rbac/mod.rs b/hermes/apps/athena/modules/auth/src/rbac/mod.rs new file mode 100644 index 0000000000..122c7a7c62 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/rbac/mod.rs @@ -0,0 +1,6 @@ +//! Catalyst RBAC registration + +pub(crate) mod build_rbac_chain; +pub(crate) mod get_rbac; +pub(crate) mod rbac_chain_metadata; +pub(crate) mod registration_location; diff --git a/hermes/apps/athena/modules/auth/src/rbac/rbac_chain_metadata.rs b/hermes/apps/athena/modules/auth/src/rbac/rbac_chain_metadata.rs new file mode 100644 index 0000000000..68afbec5a9 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/rbac/rbac_chain_metadata.rs @@ -0,0 +1,14 @@ +//! RBAC chain metadata. + +use cardano_blockchain_types::{hashes::TransactionId, Slot}; + +/// RBAC chain metadata. +#[derive(Debug, Clone, Default)] +pub(crate) struct RbacChainMetadata { + /// Last persistent transaction. + pub(crate) last_persistent_txn: Option, + /// Last volatile transaction. + pub(crate) last_volatile_txn: Option, + /// Last persistent slot. + pub(crate) last_persistent_slot: Slot, +} diff --git a/hermes/apps/athena/modules/auth/src/rbac/registration_location.rs b/hermes/apps/athena/modules/auth/src/rbac/registration_location.rs new file mode 100644 index 0000000000..fa7a949491 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/rbac/registration_location.rs @@ -0,0 +1,12 @@ +//! The location of the registration. + +/// Information needed to build the RBAC chain. +/// Only need the `slot_no` and `txn_idx` to construct a block and +/// extract the RBAC information. +#[derive(Debug, Clone)] +pub(crate) struct RegistrationLocation { + /// The slot number of the block that contains the registration. + pub(crate) slot_no: u64, + /// The transaction index that contains the registration. + pub(crate) txn_idx: u16, +} From 9ef92e7bfb9442be3d11b5d958da6d6f8af4a4b1 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 18:50:04 +0700 Subject: [PATCH 12/24] add auth mod to just Signed-off-by: bkioshn --- justfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/justfile b/justfile index 95eb146188..ad0d767e0c 100644 --- a/justfile +++ b/justfile @@ -103,6 +103,7 @@ get-local-athena: earthly ./hermes/apps/athena/modules/http-proxy+local-build-http-proxy earthly ./hermes/apps/athena/modules/rbac-registration-indexer+local-build-rbac-registration-indexer earthly ./hermes/apps/athena/modules/rbac-registration+local-build-rbac-registration + earthly ./hermes/apps/athena/modules/auth+local-build-auth echo "✅ WASM compilation complete" @@ -114,6 +115,7 @@ get-local-athena: target/release/hermes module package hermes/apps/athena/modules/http-proxy/lib/manifest_module.json target/release/hermes module package hermes/apps/athena/modules/rbac-registration-indexer/lib/manifest_module.json target/release/hermes module package hermes/apps/athena/modules/rbac-registration/lib/manifest_module.json + target/release/hermes module package hermes/apps/athena/modules/auth/lib/manifest_module.json echo "✅ Module packaging complete (.hmod file created)" echo "📦 Packaging application bundle..." From 92e9063983035a3c010b1e2dac6f13babde7c519 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 18:52:19 +0700 Subject: [PATCH 13/24] impl auth module Signed-off-by: bkioshn --- .../apps/athena/modules/auth/src/api_keys.rs | 34 ++ hermes/apps/athena/modules/auth/src/lib.rs | 106 ++++++ .../apps/athena/modules/auth/src/response.rs | 76 ++++ hermes/apps/athena/modules/auth/src/token.rs | 353 ++++++++++++++++++ hermes/apps/athena/modules/auth/src/utils.rs | 100 +++++ .../athena/modules/auth/src/validation.rs | 109 ++++++ .../athena/shared/src/utils/cardano/mod.rs | 32 +- 7 files changed, 799 insertions(+), 11 deletions(-) create mode 100644 hermes/apps/athena/modules/auth/src/api_keys.rs create mode 100644 hermes/apps/athena/modules/auth/src/lib.rs create mode 100644 hermes/apps/athena/modules/auth/src/response.rs create mode 100644 hermes/apps/athena/modules/auth/src/token.rs create mode 100644 hermes/apps/athena/modules/auth/src/utils.rs create mode 100644 hermes/apps/athena/modules/auth/src/validation.rs diff --git a/hermes/apps/athena/modules/auth/src/api_keys.rs b/hermes/apps/athena/modules/auth/src/api_keys.rs new file mode 100644 index 0000000000..3bb94ace8c --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/api_keys.rs @@ -0,0 +1,34 @@ +//! API Key auth scheme is used ONLY by internal endpoints. +//! +//! Its purpose is to prevent their use externally, if they were accidentally exposed. +//! +//! It is NOT to be used on any endpoint intended to be publicly facing. + +use std::env; + +use anyhow::{bail, Result}; +use shared::utils::log::info; + +use crate::{extract_header, hermes::http_gateway::api::Headers}; + +/// The header name that holds the API Key +pub(crate) const API_KEY_HEADER: &str = "X-API-Key"; + +/// Check if the API Key is correctly set. +pub(crate) fn check_api_key(headers: Headers) -> Result<()> { + if let Some(key) = extract_header!(headers, API_KEY_HEADER) { + if check_internal_api_key(&key) { + return Ok(()); + } + } + bail!("Invalid API Key"); +} + +/// Check a given key matches the internal API Key +fn check_internal_api_key(value: &str) -> bool { + // TODO: This should be moved to application setting/config + match env::var("INTERNAL_API_KEY") { + Ok(expected_key) => value == expected_key, + Err(_) => false, + } +} diff --git a/hermes/apps/athena/modules/auth/src/lib.rs b/hermes/apps/athena/modules/auth/src/lib.rs new file mode 100644 index 0000000000..73b22974ab --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/lib.rs @@ -0,0 +1,106 @@ +//! Auth Module + +shared::bindings_generate!({ + world: "hermes:app/hermes", + path: "../../../../../wasm/wasi/wit", + inline: " + package hermes:app; + + world hermes { + include wasi:cli/imports@0.2.6; + import hermes:logging/api; + import hermes:http-gateway/api; + + export hermes:http-gateway/event-auth; + } + ", + share: ["hermes:logging"], +}); + +mod api_keys; +mod database; +mod rbac; +mod response; +mod token; +mod utils; +mod validation; + +use shared::{ + bindings::hermes::cardano, + utils::log::{self, log_info}, +}; + +use crate::{ + hermes::http_gateway::api::{AuthRequest, Bstr, HttpResponse}, + response::{AuthResponse, AuthTokenError}, + validation::checker_api_catalyst_auth, +}; + +export!(AuthComponent); + +struct AuthComponent; + +impl AuthComponent { + /// Create an HTTP response from AuthResponse + fn make_response(auth: &AuthResponse) -> HttpResponse { + let headers = vec![( + "content-type".to_string(), + vec!["application/json".to_string()], + )]; + // Attempt to serialize, fallback to 500 if it fails + let (code, body) = match auth.to_json() { + Ok(body) => (auth.status_code(), body), + Err(e) => ( + AuthResponse::InternalServerError(e.to_string()).status_code(), + serde_json::json!({ + "error": format!("Internal Server Error: Failed to serialize response: {e}") + }) + .to_string(), + ), + }; + HttpResponse { + code, + headers, + body: Bstr::from(body), + } + } + + /// Validate a token and return HTTP response + fn validate_token( + token: &str, + headers: Vec<(String, Vec)>, + network: cardano::api::CardanoNetwork, + ) -> HttpResponse { + let result = checker_api_catalyst_auth(headers, token, network); + Self::make_response(&result) + } +} + +impl exports::hermes::http_gateway::event_auth::Guest for AuthComponent { + fn validate_auth(request: AuthRequest) -> Option { + log::init(log::LevelFilter::Info); + + match request.auth_level { + hermes::http_gateway::api::AuthLevel::Required => { + if let Some(t) = token { + Some(Self::validate_token(&t, request.headers, network)) + } else { + Some(Self::make_response(&AuthResponse::Unauthorized( + AuthTokenError::MissingToken, + ))) + } + }, + // If the auth is present, validate it, if not skip it + hermes::http_gateway::api::AuthLevel::Optional => { + if let Some(t) = token { + Some(Self::validate_token(&t, request.headers, network)) + } else { + Some(Self::make_response(&AuthResponse::Ok)) + } + }, + hermes::http_gateway::api::AuthLevel::None => { + Some(Self::make_response(&AuthResponse::Ok)) + }, + } + } +} diff --git a/hermes/apps/athena/modules/auth/src/response.rs b/hermes/apps/athena/modules/auth/src/response.rs new file mode 100644 index 0000000000..033f2bfd09 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/response.rs @@ -0,0 +1,76 @@ +//! Response for auth event + +use serde::Serialize; +use thiserror::Error; + +/// Authentication token error (401 Unauthorized). +#[derive(Debug, Error, Clone, Serialize)] +pub enum AuthTokenError { + /// Registration chain cannot be built. + #[error("Unable to build registration chain, err: {0}")] + BuildRegChain(String), + /// RBAC token cannot be parsed. + #[error("Fail to parse RBAC token string, err: {0}")] + ParseRbacToken(String), + /// Registration chain cannot be found. + #[error("Registration not found for the auth token.")] + RegistrationNotFound, + /// Latest signing key cannot be found. + #[error("Unable to get the latest signing key.")] + LatestSigningKey, + /// Missing auth token. + #[error("Missing auth token")] + MissingToken, +} + +/// Authorization, token does not have required access rights (403 Forbidden). +#[derive(Debug, Error, Clone, Serialize)] +#[error("Insufficient Permission for Catalyst RBAC Token: {0:?}")] +pub struct AuthTokenAccessViolation(pub Vec); + +/// Auth response enum +#[derive(Debug, Clone)] +pub enum AuthResponse { + /// Auth successful (200 OK) + Ok, + /// Invalid or missing token (401 Unauthorized) + Unauthorized(AuthTokenError), + /// Valid token but insufficient permissions (403 Forbidden) + Forbidden(AuthTokenAccessViolation), + /// External service unavailable/dependency error (503 Service Unavailable) + ServiceUnavailable(String), + /// Internal server error + InternalServerError(String), +} + +impl AuthResponse { + /// Convert response to HTTP status code + pub fn status_code(&self) -> u16 { + match self { + AuthResponse::Ok => 200, + AuthResponse::Unauthorized(_) => 401, + AuthResponse::Forbidden(_) => 403, + AuthResponse::ServiceUnavailable(_) => 503, + AuthResponse::InternalServerError(_) => 500, + } + } + + /// Serialize response body to JSON + pub fn to_json(&self) -> Result { + match self { + AuthResponse::Ok => Ok("Authentication success".to_string()), + AuthResponse::Unauthorized(msg) => { + serde_json::to_string(&serde_json::json!({"Unauthorized": msg.to_string()})) + }, + AuthResponse::Forbidden(msg) => { + serde_json::to_string(&serde_json::json!({"Forbidden": msg.to_string()})) + }, + AuthResponse::ServiceUnavailable(msg) => { + serde_json::to_string(&serde_json::json!({"Service Unavailable": msg.to_string()})) + }, + AuthResponse::InternalServerError(msg) => serde_json::to_string( + &serde_json::json!({"Internal Server Error": msg.to_string()}), + ), + } + } +} diff --git a/hermes/apps/athena/modules/auth/src/token.rs b/hermes/apps/athena/modules/auth/src/token.rs new file mode 100644 index 0000000000..c3d4412fbb --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/token.rs @@ -0,0 +1,353 @@ +//! Catalyst RBAC Token utility functions. + +use std::{ + fmt::{Display, Formatter}, + sync::LazyLock, + time::Duration, +}; + +use anyhow::Context; +use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; +use cardano_blockchain_types::Network; +use catalyst_types::catalyst_id::CatalystId; +use chrono::{TimeDelta, Utc}; +use ed25519_dalek::{ed25519::signature::Signer, Signature, SigningKey, VerifyingKey}; +use rbac_registration::registration::cardano::RegistrationChain; +use regex::Regex; +use shared::bindings::hermes::{cardano, sqlite::api::Sqlite}; + +use crate::rbac::get_rbac::get_rbac_chain_from_cat_id; + +/// Captures just the digits after last slash. +/// Use to filter out the rotation and role in catalyst ID. +/// This Regex should not fail +#[allow(clippy::unwrap_used)] +static REGEX: LazyLock = LazyLock::new(|| Regex::new(r"/\d+$").unwrap()); + +/// A Catalyst RBAC Authorization Token. +/// +/// See [this document] for more details. +/// +/// [this document]: https://github.com/input-output-hk/catalyst-voices/blob/main/docs/src/catalyst-standards/permissionless-auth/auth-header.md +#[derive(Debug, Clone)] +pub(crate) struct CatalystRBACTokenV1 { + /// A Catalyst identifier. + catalyst_id: CatalystId, + /// A network value. + /// + /// The network value is contained in the Catalyst ID and can be accessed from it, but + /// it is a string, so we convert it to this enum during the validation. + network: Network, + /// Ed25519 Signature of the Token + 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 { + /// Bearer Token prefix for this token. + const AUTH_TOKEN_PREFIX: &str = "catid."; + + /// Creates a new token instance. + pub(crate) fn new( + network: &str, + subnet: Option<&str>, + role0_pk: VerifyingKey, + sk: &SigningKey, + ) -> anyhow::Result { + // Create a catid and set nonce to the current time + let catalyst_id = CatalystId::new(network, subnet, role0_pk) + .with_nonce() + .as_id(); + + let network = convert_network(&catalyst_id.network())?; + let raw = as_raw_bytes(&catalyst_id.to_string()); + let signature = sk.sign(&raw); + + Ok(Self { + catalyst_id, + network, + signature, + raw, + reg_chain: None, + }) + } + + /// Parses a token from the given string. + /// + /// The token consists of the following parts: + /// - "catid" prefix. + /// - Nonce. + /// - Network. + /// - Role 0 public key. + /// - Signature. + /// + /// For example: + /// ``` + /// catid.:173710179@preprod.cardano/FftxFnOrj2qmTuB2oZG2v0YEWJfKvQ9Gg8AgNAhDsKE. + /// ``` + pub(crate) fn parse(token: &str) -> anyhow::Result { + let token = token + .trim() + .strip_prefix(Self::AUTH_TOKEN_PREFIX) + .ok_or_else(|| anyhow::anyhow!("Missing token prefix"))?; + let (token, signature) = token + .rsplit_once('.') + .ok_or_else(|| anyhow::anyhow!("Missing token signature"))?; + let signature = BASE64_URL_SAFE_NO_PAD + .decode(signature.as_bytes()) + .context("Invalid token signature encoding")? + .try_into() + .map(|b| Signature::from_bytes(&b)) + .map_err(|_| anyhow::anyhow!("Invalid token signature length"))?; + let raw = as_raw_bytes(token); + + let catalyst_id: CatalystId = token.parse().context("Invalid Catalyst ID")?; + if catalyst_id.username().is_some_and(|n| !n.is_empty()) { + return Err(anyhow::anyhow!("Catalyst ID must not contain username")); + } + if !catalyst_id.clone().is_id() { + return Err(anyhow::anyhow!("Catalyst ID must be in an ID format")); + } + if catalyst_id.nonce().is_none() { + return Err(anyhow::anyhow!("Catalyst ID must have nonce")); + } + + if REGEX.is_match(token) { + return Err(anyhow::anyhow!( + "Catalyst ID mustn't have role or rotation specified" + )); + } + let network = convert_network(&catalyst_id.network())?; + + Ok(Self { + catalyst_id, + network, + signature, + raw, + reg_chain: None, + }) + } + + /// Given the `PublicKey`, verifies the token was correctly signed. + pub(crate) fn verify( + &self, + public_key: &VerifyingKey, + ) -> anyhow::Result<()> { + public_key + .verify_strict(&self.raw, &self.signature) + .context("Token signature verification failed") + } + + /// Checks that the token timestamp is valid. + /// + /// The timestamp is valid if it isn't too old or too skewed. + pub(crate) fn is_young( + &self, + max_age: Duration, + max_skew: Duration, + ) -> bool { + let Some(token_age) = self.catalyst_id.nonce() else { + return false; + }; + + let now = Utc::now(); + + // The token is considered old if it was issued more than max_age ago. + // And newer than an allowed clock skew value + // This is a safety measure to avoid replay attacks. + let Ok(max_age) = TimeDelta::from_std(max_age) else { + return false; + }; + let Ok(max_skew) = TimeDelta::from_std(max_skew) else { + return false; + }; + let Some(min_time) = now.checked_sub_signed(max_age) else { + return false; + }; + let Some(max_time) = now.checked_add_signed(max_skew) else { + return false; + }; + (min_time < token_age) && (max_time > token_age) + } + + /// Returns a Catalyst ID from the token. + pub(crate) fn catalyst_id(&self) -> &CatalystId { + &self.catalyst_id + } + + /// Returns a network. + #[allow(dead_code)] + pub(crate) fn network(&self) -> Network { + self.network + } + + /// Returns a corresponded registration chain if any registrations present. + pub(crate) fn reg_chain( + &mut self, + persistent: &Sqlite, + volatile: &Sqlite, + network_resource: &cardano::api::Network, + ) -> anyhow::Result> { + if self.reg_chain.is_none() { + let Some((reg_chain, _)) = get_rbac_chain_from_cat_id( + persistent, + volatile, + &self.catalyst_id.as_short_id(), + self.network.into(), + network_resource, + )? + else { + return Ok(None); + }; + + self.reg_chain = Some(reg_chain); + } + Ok(self.reg_chain.clone()) + } +} + +impl Display for CatalystRBACTokenV1 { + fn fmt( + &self, + f: &mut Formatter<'_>, + ) -> std::fmt::Result { + write!( + f, + "{}{}.{}", + CatalystRBACTokenV1::AUTH_TOKEN_PREFIX, + self.catalyst_id, + BASE64_URL_SAFE_NO_PAD.encode(self.signature.to_bytes()) + ) + } +} + +/// Converts the given token string to raw bytes. +fn as_raw_bytes(token: &str) -> Vec { + // The signature is calculated over all bytes in the token including the final '.'. + CatalystRBACTokenV1::AUTH_TOKEN_PREFIX + .bytes() + .chain(token.bytes()) + .chain(".".bytes()) + .collect() +} + +/// Checks if the given network is supported. +fn convert_network((network, subnet): &(String, Option)) -> anyhow::Result { + if network != "cardano" { + return Err(anyhow::anyhow!("Unsupported network: {network}")); + } + + match subnet.as_deref() { + None => Ok(Network::Mainnet), + Some("preprod") => Ok(Network::Preprod), + Some("preview") => Ok(Network::Preview), + Some(subnet) => Err(anyhow::anyhow!("Unsupported host: {subnet}.{network}",)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration as ChronoDuration, Utc}; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use rand::RngCore; + use std::time::Duration; // <-- added this + use test_case::test_case; + + fn generate_signing_key() -> SigningKey { + let mut rng = OsRng; + let mut key_bytes = [0u8; 32]; + rng.fill_bytes(&mut key_bytes); + SigningKey::from_bytes(&key_bytes) + } + + #[test_case("cardano", None ; "mainnet cardano network")] + #[test_case("cardano", Some("preprod") ; "preprod.cardano network")] + #[test_case("cardano", Some("preview") ; "preview.cardano network")] + fn roundtrip( + network: &'static str, + subnet: Option<&'static str>, + ) { + let signing_key = generate_signing_key(); + let verifying_key = signing_key.verifying_key(); + + let token = CatalystRBACTokenV1::new(network, subnet, verifying_key, &signing_key).unwrap(); + assert_eq!(token.catalyst_id().username(), None); + assert!(token.catalyst_id().nonce().is_some()); + assert_eq!( + token.catalyst_id().network(), + (network.to_string(), subnet.map(ToString::to_string)) + ); + assert!(!token.catalyst_id().is_encryption_key()); + assert!(token.catalyst_id().is_signature_key()); + + let token_str = token.to_string(); + let parsed = CatalystRBACTokenV1::parse(&token_str).unwrap(); + assert_eq!(token.signature, parsed.signature); + assert_eq!(token.raw, parsed.raw); + assert_eq!(parsed.catalyst_id().username(), Some(String::new())); + assert!(parsed.catalyst_id().nonce().is_some()); + assert_eq!( + parsed.catalyst_id().network(), + (network.to_string(), subnet.map(ToString::to_string)) + ); + assert!(!token.catalyst_id().is_encryption_key()); + assert!(token.catalyst_id().is_signature_key()); + + let parsed_str = parsed.to_string(); + assert_eq!(token_str, parsed_str); + } + + #[test] + fn is_young() { + let signing_key = generate_signing_key(); + let verifying_key = signing_key.verifying_key(); + + let mut token = + CatalystRBACTokenV1::new("cardano", Some("preprod"), verifying_key, &signing_key) + .unwrap(); + + let now = Utc::now(); + + // Move timestamp 2s to the past (chrono duration) + token.catalyst_id = token + .catalyst_id + .with_specific_nonce(now - ChronoDuration::seconds(2)); + + // std::time::Duration for is_young() + let max_age = Duration::from_secs(1); + let max_skew = Duration::from_secs(1); + assert!(!token.is_young(max_age, max_skew)); + + let max_age = Duration::from_secs(3); + assert!(token.is_young(max_age, max_skew)); + + // Move timestamp 2s to the future + token.catalyst_id = token + .catalyst_id + .with_specific_nonce(now + ChronoDuration::seconds(2)); + + let max_skew = Duration::from_secs(1); + assert!(!token.is_young(max_age, max_skew)); + + let max_skew = Duration::from_secs(3); + assert!(token.is_young(max_age, max_skew)); + } + + #[test] + fn verify() { + let signing_key = generate_signing_key(); + let verifying_key = signing_key.verifying_key(); + + let token = + CatalystRBACTokenV1::new("cardano", Some("preprod"), verifying_key, &signing_key) + .unwrap(); + + token.verify(&verifying_key).unwrap(); + } +} diff --git a/hermes/apps/athena/modules/auth/src/utils.rs b/hermes/apps/athena/modules/auth/src/utils.rs new file mode 100644 index 0000000000..48e5afac36 --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/utils.rs @@ -0,0 +1,100 @@ +//! Utilities functions + +/// Macro to extract headers with optional prefix filtering +#[macro_export] +macro_rules! extract_header { + // Extract header without prefix + ($headers:expr, $name:expr) => { + $headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case($name)) + .and_then(|(_, values)| values.first().cloned()) + }; + + // Extract header with prefix and strip it + ($headers:expr, $name:expr, $prefix:expr) => { + $headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case($name)) + .and_then(|(_, values)| values.first().cloned()) + .filter(|value| value.starts_with($prefix)) + .and_then(|value| value.strip_prefix($prefix).map(|s| s.to_string())) + }; +} + +#[cfg(test)] +mod tests { + use test_case::test_case; + + #[test_case( + vec![ + ("content-type".to_string(), vec!["application/json".to_string()]), + ("authorization".to_string(), vec!["Bearer catid.123@test.com/signature".to_string()]), + ], + "authorization", + Some("Bearer catid.123@test.com/signature".to_string()) + ; "extract header without prefix" + )] + fn test_extract_header_without_prefix( + headers: Vec<(String, Vec)>, + header_name: &str, + expected: Option, + ) { + let result = extract_header!(headers, header_name); + assert_eq!(result, expected); + } + + #[test_case( + vec![ + ("content-type".to_string(), vec!["application/json".to_string()]), + ("authorization".to_string(), vec!["Bearer catid.123@test.com/signature".to_string()]), + ], + "authorization", + "Bearer ", + Some("catid.123@test.com/signature".to_string()) + ; "extract header with Bearer prefix - should strip the prefix" + )] + #[test_case( + vec![ + ("content-type".to_string(), vec!["application/json".to_string()]), + ("authorization".to_string(), vec!["Basic dXNlcjpwYXNz".to_string()]), + ], + "authorization", + "Bearer ", + None + ; "extract header with Bearer prefix when header has Basic auth" + )] + #[test_case( + vec![("Authorization".to_string(), vec!["Bearer catid.123@test.com/signature".to_string()])], + "authorization", + "Bearer ", + Some("catid.123@test.com/signature".to_string()) + ; "case insensitive header name matching" + )] + #[test_case( + vec![("content-type".to_string(), vec!["application/json".to_string()])], + "authorization", + "Bearer ", + None + ; "extract non-existent header" + )] + #[test_case( + vec![("authorization".to_string(), vec![ + "Bearer catid.123@test.com/signature".to_string(), + "Bearer catid.456@test.com/signature2".to_string(), + ])], + "authorization", + "Bearer ", + Some("catid.123@test.com/signature".to_string()) + ; "extract header with multiple values - should return first match" + )] + fn test_extract_header_with_prefix( + headers: Vec<(String, Vec)>, + header_name: &str, + prefix: &str, + expected: Option, + ) { + let result = extract_header!(headers, header_name, prefix); + assert_eq!(result, expected); + } +} diff --git a/hermes/apps/athena/modules/auth/src/validation.rs b/hermes/apps/athena/modules/auth/src/validation.rs new file mode 100644 index 0000000000..2bc54d416e --- /dev/null +++ b/hermes/apps/athena/modules/auth/src/validation.rs @@ -0,0 +1,109 @@ +//! RBAC token validation logic. + +use std::time::Duration; + +use catalyst_types::catalyst_id::role_index::RoleId; +use rbac_registration::registration::cardano::RegistrationChain; +use shared::{ + bindings::hermes::cardano, + utils::{log::warn, sqlite::open_db_connection}, +}; + +use crate::{ + api_keys::check_api_key, + hermes::http_gateway::api::Headers, + response::{AuthResponse, AuthTokenAccessViolation, AuthTokenError}, +}; + +use super::token::CatalystRBACTokenV1; + +/// Time in the past the Token can be valid for. +const MAX_TOKEN_AGE: Duration = Duration::from_secs(60 * 60); // 1 hour. + +/// Time in the future the Token can be valid for. +const MAX_TOKEN_SKEW: Duration = Duration::from_secs(5 * 60); // 5 minutes + +/// [here]: https://github.com/input-output-hk/catalyst-voices/blob/main/docs/src/catalyst-standards/permissionless-auth/auth-header.md#backend-processing-of-the-token +pub fn checker_api_catalyst_auth( + headers: Headers, + bearer_token: &str, + network: cardano::api::CardanoNetwork, +) -> AuthResponse { + // Step 1-5: Parse and validate token format + let mut token = match CatalystRBACTokenV1::parse(bearer_token) { + Ok(token) => token, + Err(e) => { + return AuthResponse::Unauthorized(AuthTokenError::ParseRbacToken(e.to_string())); + }, + }; + + // Step 6: Get the registration chain + let reg_chain = match get_registration(network, &mut token) { + Ok(chain) => chain, + Err(e) => { + return e; + }, + }; + + // Step 7: Verify that the nonce is in the acceptable range. + // If `InternalApiKeyAuthorization` auth is provided, skip validation. + if check_api_key(headers).is_err() && !token.is_young(MAX_TOKEN_AGE, MAX_TOKEN_SKEW) { + // Token is too old or too far in the future. + warn!("Auth token expired: {token}"); + return AuthResponse::Forbidden(AuthTokenAccessViolation(vec!["EXPIRED".to_string()])); + } + + // Step 8: Get the latest stable signing certificate registered for Role 0. + let Some((latest_pk, _)) = reg_chain.get_latest_signing_pk_for_role(&RoleId::Role0) else { + warn!( + "Unable to get last signing key for {} Catalyst ID", + token.catalyst_id() + ); + return AuthResponse::Unauthorized(AuthTokenError::LatestSigningKey); + }; + + // Step 9: Verify the signature against the Role 0 pk. + + if let Err(_) = token.verify(&latest_pk) { + warn!("Invalid signature for token: {token}"); + return AuthResponse::Forbidden(AuthTokenAccessViolation(vec![ + "INVALID SIGNATURE".to_string() + ])); + } + + // Step 10 is optional and isn't currently implemented. + // - Get the latest unstable signing certificate registered for Role 0. + // - Verify the signature against the Role 0 Public Key and Algorithm identified by the + // certificate. If this fails, return 403. + + // Step 11: Token is valid + AuthResponse::Ok +} + +/// Get the registration chain. +fn get_registration( + network: cardano::api::CardanoNetwork, + token: &mut CatalystRBACTokenV1, +) -> anyhow::Result { + let persistent = open_db_connection(false).map_err(|_| { + AuthResponse::ServiceUnavailable("Failed to open persistent database".to_string()) + })?; + + let volatile = open_db_connection(false).map_err(|_| { + AuthResponse::ServiceUnavailable("Failed to open volatile database".to_string()) + })?; + + let network_resource = cardano::api::Network::new(network).map_err(|e| { + AuthResponse::ServiceUnavailable(format!("Failed to create network resource: {e}")) + })?; + + match token.reg_chain(&persistent, &volatile, &network_resource) { + Ok(Some(chain)) => Ok(chain), + Ok(None) => Err(AuthResponse::Unauthorized( + AuthTokenError::RegistrationNotFound, + )), + Err(e) => Err(AuthResponse::Unauthorized(AuthTokenError::BuildRegChain( + e.to_string(), + ))), + } +} diff --git a/hermes/apps/athena/shared/src/utils/cardano/mod.rs b/hermes/apps/athena/shared/src/utils/cardano/mod.rs index b6e7854bab..3340f4c278 100644 --- a/hermes/apps/athena/shared/src/utils/cardano/mod.rs +++ b/hermes/apps/athena/shared/src/utils/cardano/mod.rs @@ -2,9 +2,7 @@ pub mod block; -use serde_json::json; - -use crate::{bindings::hermes::cardano, utils::log::log_error}; +use crate::bindings::hermes::cardano; impl From for cardano_blockchain_types::Network { fn from(network: cardano::api::CardanoNetwork) -> cardano_blockchain_types::Network { @@ -15,14 +13,26 @@ impl From for cardano_blockchain_types::Network { cardano::api::CardanoNetwork::TestnetMagic(n) => { // TODO(bkioshn) - This should be mapped to // cardano_blockchain_types::Network::Devnet - log_error( - file!(), - "From for cardano_blockchain_types::Network", - "cardano::api::CardanoNetwork::TestnetMagic", - "Unsupported network", - Some(&json!({ "network": format!("TestnetMagic {n}") }).to_string()), - ); - panic!("Unsupported network"); + let err = format!("Unsupported network TestnetMagic {n}"); + panic!("{err}"); + }, + } + } +} + +impl From for cardano::api::CardanoNetwork { + fn from(network: cardano_blockchain_types::Network) -> cardano::api::CardanoNetwork { + match network { + cardano_blockchain_types::Network::Mainnet => cardano::api::CardanoNetwork::Mainnet, + cardano_blockchain_types::Network::Preprod => cardano::api::CardanoNetwork::Preprod, + cardano_blockchain_types::Network::Preview => cardano::api::CardanoNetwork::Preview, + cardano_blockchain_types::Network::Devnet { magic, .. } => { + cardano::api::CardanoNetwork::TestnetMagic(magic) + }, + _ => { + // Handle any future variants added due to #[non_exhaustive] + let err = format!("Unknown network variant {network:?}"); + panic!("{err}"); }, } } From d22d1f9484639cbb6450a9ad967ed56271704152 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 19:17:08 +0700 Subject: [PATCH 14/24] typo Signed-off-by: bkioshn --- hermes/apps/athena/modules/auth/src/lib.rs | 8 ++++---- hermes/apps/athena/modules/auth/src/token.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hermes/apps/athena/modules/auth/src/lib.rs b/hermes/apps/athena/modules/auth/src/lib.rs index 73b22974ab..b999571a0e 100644 --- a/hermes/apps/athena/modules/auth/src/lib.rs +++ b/hermes/apps/athena/modules/auth/src/lib.rs @@ -25,10 +25,7 @@ mod token; mod utils; mod validation; -use shared::{ - bindings::hermes::cardano, - utils::log::{self, log_info}, -}; +use shared::{bindings::hermes::cardano, utils::log}; use crate::{ hermes::http_gateway::api::{AuthRequest, Bstr, HttpResponse}, @@ -80,6 +77,9 @@ impl exports::hermes::http_gateway::event_auth::Guest for AuthComponent { fn validate_auth(request: AuthRequest) -> Option { log::init(log::LevelFilter::Info); + let network = cardano::api::CardanoNetwork::Preprod; + let token = extract_header!(request.headers, "Authorization", "Bearer"); + match request.auth_level { hermes::http_gateway::api::AuthLevel::Required => { if let Some(t) = token { diff --git a/hermes/apps/athena/modules/auth/src/token.rs b/hermes/apps/athena/modules/auth/src/token.rs index c3d4412fbb..aa3f53db85 100644 --- a/hermes/apps/athena/modules/auth/src/token.rs +++ b/hermes/apps/athena/modules/auth/src/token.rs @@ -256,7 +256,7 @@ mod tests { use ed25519_dalek::SigningKey; use rand::rngs::OsRng; use rand::RngCore; - use std::time::Duration; // <-- added this + use std::time::Duration; use test_case::test_case; fn generate_signing_key() -> SigningKey { From e49f4ce16e84031567666a58d4059daebd9a83d7 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 20:03:30 +0700 Subject: [PATCH 15/24] fix cspell Signed-off-by: bkioshn --- hermes/apps/athena/modules/auth/src/token.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hermes/apps/athena/modules/auth/src/token.rs b/hermes/apps/athena/modules/auth/src/token.rs index aa3f53db85..8fd1615231 100644 --- a/hermes/apps/athena/modules/auth/src/token.rs +++ b/hermes/apps/athena/modules/auth/src/token.rs @@ -1,5 +1,7 @@ //! Catalyst RBAC Token utility functions. +// cSpell:ignoreRegExp cardano/Fftx + use std::{ fmt::{Display, Formatter}, sync::LazyLock, From 324ab51e51baa79d862370e18cd470aacdefe20f Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 20:13:44 +0700 Subject: [PATCH 16/24] cleanup Signed-off-by: bkioshn --- .../apps/athena/modules/auth/src/api_keys.rs | 1 - hermes/apps/athena/modules/auth/src/lib.rs | 28 +++++++++---------- .../apps/athena/modules/auth/src/response.rs | 8 ++++-- hermes/apps/athena/modules/auth/src/token.rs | 13 +++++---- .../athena/modules/auth/src/validation.rs | 21 ++++++++------ 5 files changed, 38 insertions(+), 33 deletions(-) diff --git a/hermes/apps/athena/modules/auth/src/api_keys.rs b/hermes/apps/athena/modules/auth/src/api_keys.rs index 3bb94ace8c..f5d9567453 100644 --- a/hermes/apps/athena/modules/auth/src/api_keys.rs +++ b/hermes/apps/athena/modules/auth/src/api_keys.rs @@ -7,7 +7,6 @@ use std::env; use anyhow::{bail, Result}; -use shared::utils::log::info; use crate::{extract_header, hermes::http_gateway::api::Headers}; diff --git a/hermes/apps/athena/modules/auth/src/lib.rs b/hermes/apps/athena/modules/auth/src/lib.rs index b999571a0e..7838112e46 100644 --- a/hermes/apps/athena/modules/auth/src/lib.rs +++ b/hermes/apps/athena/modules/auth/src/lib.rs @@ -40,21 +40,21 @@ struct AuthComponent; impl AuthComponent { /// Create an HTTP response from AuthResponse fn make_response(auth: &AuthResponse) -> HttpResponse { - let headers = vec![( - "content-type".to_string(), - vec!["application/json".to_string()], - )]; + let headers = vec![("content-type".to_string(), vec![ + "application/json".to_string() + ])]; // Attempt to serialize, fallback to 500 if it fails - let (code, body) = match auth.to_json() { - Ok(body) => (auth.status_code(), body), - Err(e) => ( - AuthResponse::InternalServerError(e.to_string()).status_code(), - serde_json::json!({ - "error": format!("Internal Server Error: Failed to serialize response: {e}") - }) - .to_string(), - ), - }; + let (code, body) = + match auth.to_json() { + Ok(body) => (auth.status_code(), body), + Err(e) => ( + AuthResponse::InternalServerError(e.to_string()).status_code(), + serde_json::json!({ + "error": format!("Internal Server Error: Failed to serialize response: {e}") + }) + .to_string(), + ), + }; HttpResponse { code, headers, diff --git a/hermes/apps/athena/modules/auth/src/response.rs b/hermes/apps/athena/modules/auth/src/response.rs index 033f2bfd09..331ece3abf 100644 --- a/hermes/apps/athena/modules/auth/src/response.rs +++ b/hermes/apps/athena/modules/auth/src/response.rs @@ -68,9 +68,11 @@ impl AuthResponse { AuthResponse::ServiceUnavailable(msg) => { serde_json::to_string(&serde_json::json!({"Service Unavailable": msg.to_string()})) }, - AuthResponse::InternalServerError(msg) => serde_json::to_string( - &serde_json::json!({"Internal Server Error": msg.to_string()}), - ), + AuthResponse::InternalServerError(msg) => { + serde_json::to_string( + &serde_json::json!({"Internal Server Error": msg.to_string()}), + ) + }, } } } diff --git a/hermes/apps/athena/modules/auth/src/token.rs b/hermes/apps/athena/modules/auth/src/token.rs index 8fd1615231..1a2a8b0eb2 100644 --- a/hermes/apps/athena/modules/auth/src/token.rs +++ b/hermes/apps/athena/modules/auth/src/token.rs @@ -1,7 +1,5 @@ //! Catalyst RBAC Token utility functions. -// cSpell:ignoreRegExp cardano/Fftx - use std::{ fmt::{Display, Formatter}, sync::LazyLock, @@ -54,6 +52,7 @@ impl CatalystRBACTokenV1 { const AUTH_TOKEN_PREFIX: &str = "catid."; /// Creates a new token instance. + #[allow(dead_code)] pub(crate) fn new( network: &str, subnet: Option<&str>, @@ -89,6 +88,7 @@ impl CatalystRBACTokenV1 { /// /// For example: /// ``` + // cspell:disable-next-line /// catid.:173710179@preprod.cardano/FftxFnOrj2qmTuB2oZG2v0YEWJfKvQ9Gg8AgNAhDsKE. /// ``` pub(crate) fn parse(token: &str) -> anyhow::Result { @@ -253,14 +253,15 @@ fn convert_network((network, subnet): &(String, Option)) -> anyhow::Resu #[cfg(test)] mod tests { - use super::*; + use std::time::Duration; + use chrono::{Duration as ChronoDuration, Utc}; use ed25519_dalek::SigningKey; - use rand::rngs::OsRng; - use rand::RngCore; - use std::time::Duration; + use rand::{rngs::OsRng, RngCore}; use test_case::test_case; + use super::*; + fn generate_signing_key() -> SigningKey { let mut rng = OsRng; let mut key_bytes = [0u8; 32]; diff --git a/hermes/apps/athena/modules/auth/src/validation.rs b/hermes/apps/athena/modules/auth/src/validation.rs index 2bc54d416e..c0fc1713ce 100644 --- a/hermes/apps/athena/modules/auth/src/validation.rs +++ b/hermes/apps/athena/modules/auth/src/validation.rs @@ -9,14 +9,13 @@ use shared::{ utils::{log::warn, sqlite::open_db_connection}, }; +use super::token::CatalystRBACTokenV1; use crate::{ api_keys::check_api_key, hermes::http_gateway::api::Headers, response::{AuthResponse, AuthTokenAccessViolation, AuthTokenError}, }; -use super::token::CatalystRBACTokenV1; - /// Time in the past the Token can be valid for. const MAX_TOKEN_AGE: Duration = Duration::from_secs(60 * 60); // 1 hour. @@ -64,7 +63,7 @@ pub fn checker_api_catalyst_auth( // Step 9: Verify the signature against the Role 0 pk. - if let Err(_) = token.verify(&latest_pk) { + if token.verify(&latest_pk).is_err() { warn!("Invalid signature for token: {token}"); return AuthResponse::Forbidden(AuthTokenAccessViolation(vec![ "INVALID SIGNATURE".to_string() @@ -99,11 +98,15 @@ fn get_registration( match token.reg_chain(&persistent, &volatile, &network_resource) { Ok(Some(chain)) => Ok(chain), - Ok(None) => Err(AuthResponse::Unauthorized( - AuthTokenError::RegistrationNotFound, - )), - Err(e) => Err(AuthResponse::Unauthorized(AuthTokenError::BuildRegChain( - e.to_string(), - ))), + Ok(None) => { + Err(AuthResponse::Unauthorized( + AuthTokenError::RegistrationNotFound, + )) + }, + Err(e) => { + Err(AuthResponse::Unauthorized(AuthTokenError::BuildRegChain( + e.to_string(), + ))) + }, } } From 0ea14d86343df43b1c110fc1ba3f34a9f0baf9a0 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Fri, 24 Oct 2025 20:27:41 +0700 Subject: [PATCH 17/24] cspell Signed-off-by: bkioshn --- .config/dictionaries/project.dic | 1 + hermes/apps/athena/modules/auth/src/utils.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index c553712ed5..05353a9ebb 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -122,6 +122,7 @@ renameat reqwest rfind rngs +rsplit rollouts rusqlite rustc diff --git a/hermes/apps/athena/modules/auth/src/utils.rs b/hermes/apps/athena/modules/auth/src/utils.rs index 48e5afac36..45d32b1ee0 100644 --- a/hermes/apps/athena/modules/auth/src/utils.rs +++ b/hermes/apps/athena/modules/auth/src/utils.rs @@ -57,7 +57,7 @@ mod tests { #[test_case( vec![ ("content-type".to_string(), vec!["application/json".to_string()]), - ("authorization".to_string(), vec!["Basic dXNlcjpwYXNz".to_string()]), + ("authorization".to_string(), vec!["Basic dXYXNz".to_string()]), ], "authorization", "Bearer ", From 395f0705585f59f554d7b0cf70ae251108a390e6 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Sat, 25 Oct 2025 14:18:22 +0700 Subject: [PATCH 18/24] typo Signed-off-by: bkioshn --- hermes/apps/athena/modules/auth/lib/metadata.json | 2 +- hermes/apps/athena/modules/auth/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hermes/apps/athena/modules/auth/lib/metadata.json b/hermes/apps/athena/modules/auth/lib/metadata.json index eb7f41b85d..9089582109 100644 --- a/hermes/apps/athena/modules/auth/lib/metadata.json +++ b/hermes/apps/athena/modules/auth/lib/metadata.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/input-output-hk/hermes/main/hermes/schemas/hermes_app_metadata.schema.json", "name": "Authentication and Authorization service", "version": "V0.1.0", - "description": "Catalyst RBAC authentication and authorization service", + "description": "Catalyst authentication and authorization service", "src": [ "https://github.com/input-output-hk/hermes", "https://github.com/input-output-hk/catalyst-voices" diff --git a/hermes/apps/athena/modules/auth/src/lib.rs b/hermes/apps/athena/modules/auth/src/lib.rs index 7838112e46..bf20903054 100644 --- a/hermes/apps/athena/modules/auth/src/lib.rs +++ b/hermes/apps/athena/modules/auth/src/lib.rs @@ -38,7 +38,7 @@ export!(AuthComponent); struct AuthComponent; impl AuthComponent { - /// Create an HTTP response from AuthResponse + /// Create HTTP response from `AuthResponse` fn make_response(auth: &AuthResponse) -> HttpResponse { let headers = vec![("content-type".to_string(), vec![ "application/json".to_string() From b8892662a8a74ec7b4eac4770636e7196b68d143 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Mon, 10 Nov 2025 12:04:54 +0700 Subject: [PATCH 19/24] fix format Signed-off-by: bkioshn --- hermes/apps/athena/modules/auth/src/token.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hermes/apps/athena/modules/auth/src/token.rs b/hermes/apps/athena/modules/auth/src/token.rs index 1a2a8b0eb2..48e2799a6c 100644 --- a/hermes/apps/athena/modules/auth/src/token.rs +++ b/hermes/apps/athena/modules/auth/src/token.rs @@ -7,11 +7,11 @@ use std::{ }; use anyhow::Context; -use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; +use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; use cardano_blockchain_types::Network; use catalyst_types::catalyst_id::CatalystId; use chrono::{TimeDelta, Utc}; -use ed25519_dalek::{ed25519::signature::Signer, Signature, SigningKey, VerifyingKey}; +use ed25519_dalek::{Signature, SigningKey, VerifyingKey, ed25519::signature::Signer}; use rbac_registration::registration::cardano::RegistrationChain; use regex::Regex; use shared::bindings::hermes::{cardano, sqlite::api::Sqlite}; @@ -88,7 +88,8 @@ impl CatalystRBACTokenV1 { /// /// For example: /// ``` - // cspell:disable-next-line + /// #[rustfmt::skip] + /// // cspell:disable-next-line /// catid.:173710179@preprod.cardano/FftxFnOrj2qmTuB2oZG2v0YEWJfKvQ9Gg8AgNAhDsKE. /// ``` pub(crate) fn parse(token: &str) -> anyhow::Result { @@ -257,7 +258,7 @@ mod tests { use chrono::{Duration as ChronoDuration, Utc}; use ed25519_dalek::SigningKey; - use rand::{rngs::OsRng, RngCore}; + use rand::{RngCore, rngs::OsRng}; use test_case::test_case; use super::*; From 1dad69f9eee1d88d51f0dc70202bcadc4f47e69a Mon Sep 17 00:00:00 2001 From: bkioshn Date: Mon, 10 Nov 2025 12:10:05 +0700 Subject: [PATCH 20/24] update deps Signed-off-by: bkioshn --- hermes/apps/athena/modules/auth/Cargo.toml | 6 +++--- hermes/apps/athena/modules/auth/src/token.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hermes/apps/athena/modules/auth/Cargo.toml b/hermes/apps/athena/modules/auth/Cargo.toml index 9e51b477f5..e8c49d0d90 100644 --- a/hermes/apps/athena/modules/auth/Cargo.toml +++ b/hermes/apps/athena/modules/auth/Cargo.toml @@ -18,9 +18,9 @@ rand = "0.8.5" serde_json = "1.0.142" serde = "1.0.226" -cardano-blockchain-types = { version = "0.0.6", git = "https://github.com/input-output-hk/catalyst-libs", tag = "cardano-blockchain-types/v0.0.6" } -rbac-registration = { version = "0.0.10", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "rbac-registration/v0.0.10" } -catalyst-types = { version = "0.0.7", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "catalyst-types/v0.0.7" } +cardano-blockchain-types = { version = "0.0.8", git = "https://github.com/input-output-hk/catalyst-libs", tag = "cardano-blockchain-types/v0.0.8" } +rbac-registration = { version = "0.0.14", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "rbac-registration/v0.0.14" } +catalyst-types = { version = "0.0.10", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "catalyst-types/v0.0.10" } [dev-dependencies] test-case = "3.3.1" \ No newline at end of file diff --git a/hermes/apps/athena/modules/auth/src/token.rs b/hermes/apps/athena/modules/auth/src/token.rs index 48e2799a6c..6f4c8935f4 100644 --- a/hermes/apps/athena/modules/auth/src/token.rs +++ b/hermes/apps/athena/modules/auth/src/token.rs @@ -184,8 +184,8 @@ impl CatalystRBACTokenV1 { /// Returns a network. #[allow(dead_code)] - pub(crate) fn network(&self) -> Network { - self.network + pub(crate) fn network(&self) -> &Network { + &self.network } /// Returns a corresponded registration chain if any registrations present. @@ -200,7 +200,7 @@ impl CatalystRBACTokenV1 { persistent, volatile, &self.catalyst_id.as_short_id(), - self.network.into(), + self.network.clone().into(), network_resource, )? else { From 15de06a4d3286b02b9dcb1a23c2f837a2148a2da Mon Sep 17 00:00:00 2001 From: bkioshn Date: Mon, 10 Nov 2025 15:57:30 +0700 Subject: [PATCH 21/24] chore: fix miscellaneous Signed-off-by: bkioshn --- hermes/apps/athena/Cargo.lock | 53 +++++++++++++++++++ hermes/apps/athena/modules/auth/Cargo.toml | 3 +- hermes/apps/athena/modules/auth/src/token.rs | 6 +-- .../athena/shared/src/utils/cardano/mod.rs | 1 + 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/hermes/apps/athena/Cargo.lock b/hermes/apps/athena/Cargo.lock index 4a4bf12779..b8e938d020 100644 --- a/hermes/apps/athena/Cargo.lock +++ b/hermes/apps/athena/Cargo.lock @@ -97,6 +97,26 @@ dependencies = [ "syn", ] +[[package]] +name = "auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "cardano-blockchain-types", + "catalyst-types", + "chrono", + "ed25519-dalek", + "rand", + "rbac-registration 0.0.14", + "regex", + "serde", + "serde_json", + "shared", + "test-case", + "thiserror 1.0.69", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -2425,6 +2445,39 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/hermes/apps/athena/modules/auth/Cargo.toml b/hermes/apps/athena/modules/auth/Cargo.toml index e8c49d0d90..db87e65e59 100644 --- a/hermes/apps/athena/modules/auth/Cargo.toml +++ b/hermes/apps/athena/modules/auth/Cargo.toml @@ -1,7 +1,8 @@ [package] +edition.workspace = true name = "auth" version = "0.1.0" -edition = "2021" +license = "MIT OR Apache-2.0" [lib] crate-type = ["cdylib"] diff --git a/hermes/apps/athena/modules/auth/src/token.rs b/hermes/apps/athena/modules/auth/src/token.rs index 6f4c8935f4..f6328e8848 100644 --- a/hermes/apps/athena/modules/auth/src/token.rs +++ b/hermes/apps/athena/modules/auth/src/token.rs @@ -7,11 +7,11 @@ use std::{ }; use anyhow::Context; -use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; +use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; use cardano_blockchain_types::Network; use catalyst_types::catalyst_id::CatalystId; use chrono::{TimeDelta, Utc}; -use ed25519_dalek::{Signature, SigningKey, VerifyingKey, ed25519::signature::Signer}; +use ed25519_dalek::{ed25519::signature::Signer, Signature, SigningKey, VerifyingKey}; use rbac_registration::registration::cardano::RegistrationChain; use regex::Regex; use shared::bindings::hermes::{cardano, sqlite::api::Sqlite}; @@ -258,7 +258,7 @@ mod tests { use chrono::{Duration as ChronoDuration, Utc}; use ed25519_dalek::SigningKey; - use rand::{RngCore, rngs::OsRng}; + use rand::{rngs::OsRng, RngCore}; use test_case::test_case; use super::*; diff --git a/hermes/apps/athena/shared/src/utils/cardano/mod.rs b/hermes/apps/athena/shared/src/utils/cardano/mod.rs index 4547250aa5..9a1d4eed40 100644 --- a/hermes/apps/athena/shared/src/utils/cardano/mod.rs +++ b/hermes/apps/athena/shared/src/utils/cardano/mod.rs @@ -21,6 +21,7 @@ impl From for cardano_blockchain_types::Network { } } +#[allow(clippy::panic)] impl From for cardano::api::CardanoNetwork { fn from(network: cardano_blockchain_types::Network) -> cardano::api::CardanoNetwork { match network { From 2e5a92b5c31e4b53edb1aaec34be367cc399ebe5 Mon Sep 17 00:00:00 2001 From: bkioshn Date: Tue, 11 Nov 2025 21:16:13 +0700 Subject: [PATCH 22/24] fix linter Signed-off-by: bkioshn --- hermes/apps/athena/modules/auth/Cargo.toml | 3 ++ .../apps/athena/modules/auth/src/api_keys.rs | 2 +- .../auth/src/database/query_builder.rs | 11 +++---- .../auth/src/database/select/cat_id.rs | 19 ++++++------ hermes/apps/athena/modules/auth/src/lib.rs | 10 ++++--- .../modules/auth/src/rbac/build_rbac_chain.rs | 30 ++++++++----------- .../athena/modules/auth/src/rbac/get_rbac.rs | 2 +- .../auth/src/rbac/rbac_chain_metadata.rs | 1 + hermes/apps/athena/modules/auth/src/utils.rs | 18 ++++++----- .../athena/modules/auth/src/validation.rs | 2 +- .../hermes/http_gateway/gateway_task.rs | 3 +- 11 files changed, 52 insertions(+), 49 deletions(-) diff --git a/hermes/apps/athena/modules/auth/Cargo.toml b/hermes/apps/athena/modules/auth/Cargo.toml index db87e65e59..2c4862c58b 100644 --- a/hermes/apps/athena/modules/auth/Cargo.toml +++ b/hermes/apps/athena/modules/auth/Cargo.toml @@ -4,6 +4,9 @@ name = "auth" version = "0.1.0" license = "MIT OR Apache-2.0" +[lints] +workspace = true + [lib] crate-type = ["cdylib"] diff --git a/hermes/apps/athena/modules/auth/src/api_keys.rs b/hermes/apps/athena/modules/auth/src/api_keys.rs index f5d9567453..d6bc1a6100 100644 --- a/hermes/apps/athena/modules/auth/src/api_keys.rs +++ b/hermes/apps/athena/modules/auth/src/api_keys.rs @@ -14,7 +14,7 @@ use crate::{extract_header, hermes::http_gateway::api::Headers}; pub(crate) const API_KEY_HEADER: &str = "X-API-Key"; /// Check if the API Key is correctly set. -pub(crate) fn check_api_key(headers: Headers) -> Result<()> { +pub(crate) fn check_api_key(headers: &Headers) -> Result<()> { if let Some(key) = extract_header!(headers, API_KEY_HEADER) { if check_internal_api_key(&key) { return Ok(()); diff --git a/hermes/apps/athena/modules/auth/src/database/query_builder.rs b/hermes/apps/athena/modules/auth/src/database/query_builder.rs index ad5b480f7c..0b438a5fcb 100644 --- a/hermes/apps/athena/modules/auth/src/database/query_builder.rs +++ b/hermes/apps/athena/modules/auth/src/database/query_builder.rs @@ -1,5 +1,6 @@ -//! SQLite query builders +//! `SQLite` query builders +/// Query builder pub(crate) struct QueryBuilder; impl QueryBuilder { @@ -8,7 +9,7 @@ impl QueryBuilder { /// is considered the canonical/valid registration if multiple exist. pub(crate) fn select_root_reg_by_cat_id(table: &str) -> String { format!( - r#" + r" SELECT txn_id, slot_no, txn_idx FROM {table} WHERE prv_txn_id IS NULL @@ -16,7 +17,7 @@ impl QueryBuilder { AND catalyst_id = ? ORDER BY slot_no ASC, txn_idx ASC LIMIT 1; - "# + " ) } @@ -27,14 +28,14 @@ impl QueryBuilder { /// The child is linked to the parent by the `prv_txn_id` field. pub(crate) fn select_child_reg_from_parent(table: &str) -> String { format!( - r#" + r" SELECT txn_id, slot_no, txn_idx FROM {table} WHERE prv_txn_id = ? AND problem_report IS NULL ORDER BY slot_no ASC, txn_idx ASC LIMIT 1; - "# + " ) } } diff --git a/hermes/apps/athena/modules/auth/src/database/select/cat_id.rs b/hermes/apps/athena/modules/auth/src/database/select/cat_id.rs index b022fc333c..80aec27c2a 100644 --- a/hermes/apps/athena/modules/auth/src/database/select/cat_id.rs +++ b/hermes/apps/athena/modules/auth/src/database/select/cat_id.rs @@ -78,14 +78,13 @@ pub(crate) fn select_rbac_registration_chain_from_cat_id( let mut metadata = RbacChainMetadata::default(); // --- Find the root --- - let (mut txn_id, mut chain, root_source) = - match extract_root(persistent, cat_id, RBAC_REGISTRATION_PERSISTENT_TABLE_NAME)?.or_else( - || extract_root(volatile, cat_id, RBAC_REGISTRATION_VOLATILE_TABLE_NAME).ok()?, - ) { - Some(val) => val, - None => return Ok((vec![], metadata)), - }; - + let Some((mut txn_id, mut chain, root_source)) = + extract_root(persistent, cat_id, RBAC_REGISTRATION_PERSISTENT_TABLE_NAME)?.or_else(|| { + extract_root(volatile, cat_id, RBAC_REGISTRATION_VOLATILE_TABLE_NAME).ok()? + }) + else { + return Ok((vec![], metadata)); + }; // Update tracking variable based on the root source match root_source { TableSource::Persistent => { @@ -144,8 +143,8 @@ pub(crate) fn select_rbac_registration_chain_from_cat_id( Ok(chain) })(); - let _ = DatabaseStatement::finalize_statement(p_stmt, FUNCTION_NAME); - let _ = DatabaseStatement::finalize_statement(v_stmt, FUNCTION_NAME); + let _unused = DatabaseStatement::finalize_statement(p_stmt, FUNCTION_NAME); + let _unused = DatabaseStatement::finalize_statement(v_stmt, FUNCTION_NAME); result.map(|chain| (chain, metadata)) } diff --git a/hermes/apps/athena/modules/auth/src/lib.rs b/hermes/apps/athena/modules/auth/src/lib.rs index bf20903054..3da5520f8c 100644 --- a/hermes/apps/athena/modules/auth/src/lib.rs +++ b/hermes/apps/athena/modules/auth/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(missing_docs)] //! Auth Module shared::bindings_generate!({ @@ -28,13 +29,14 @@ mod validation; use shared::{bindings::hermes::cardano, utils::log}; use crate::{ - hermes::http_gateway::api::{AuthRequest, Bstr, HttpResponse}, + hermes::http_gateway::api::{AuthRequest, Bstr, Headers, HttpResponse}, response::{AuthResponse, AuthTokenError}, validation::checker_api_catalyst_auth, }; export!(AuthComponent); +/// Auth component struct AuthComponent; impl AuthComponent { @@ -65,7 +67,7 @@ impl AuthComponent { /// Validate a token and return HTTP response fn validate_token( token: &str, - headers: Vec<(String, Vec)>, + headers: &Headers, network: cardano::api::CardanoNetwork, ) -> HttpResponse { let result = checker_api_catalyst_auth(headers, token, network); @@ -83,7 +85,7 @@ impl exports::hermes::http_gateway::event_auth::Guest for AuthComponent { match request.auth_level { hermes::http_gateway::api::AuthLevel::Required => { if let Some(t) = token { - Some(Self::validate_token(&t, request.headers, network)) + Some(Self::validate_token(&t, &request.headers, network)) } else { Some(Self::make_response(&AuthResponse::Unauthorized( AuthTokenError::MissingToken, @@ -93,7 +95,7 @@ impl exports::hermes::http_gateway::event_auth::Guest for AuthComponent { // If the auth is present, validate it, if not skip it hermes::http_gateway::api::AuthLevel::Optional => { if let Some(t) = token { - Some(Self::validate_token(&t, request.headers, network)) + Some(Self::validate_token(&t, &request.headers, network)) } else { Some(Self::make_response(&AuthResponse::Ok)) } diff --git a/hermes/apps/athena/modules/auth/src/rbac/build_rbac_chain.rs b/hermes/apps/athena/modules/auth/src/rbac/build_rbac_chain.rs index ca8bf72f32..a0e1e851ac 100644 --- a/hermes/apps/athena/modules/auth/src/rbac/build_rbac_chain.rs +++ b/hermes/apps/athena/modules/auth/src/rbac/build_rbac_chain.rs @@ -24,14 +24,13 @@ use crate::rbac::registration_location::RegistrationLocation; pub(crate) fn build_registration_chain( network: CardanoNetwork, network_resource: &Network, - registration_location: Vec, + registration_location: &[RegistrationLocation], ) -> anyhow::Result> { const FUNCTION_NAME: &str = "build_registration_chain"; // The first registration (root) - let first_info = match registration_location.first() { - Some(info) => info, - None => return Ok(None), + let Some(first_info) = registration_location.first() else { + return Ok(None); }; // Root registration use to initialize chain @@ -64,16 +63,15 @@ pub(crate) fn build_registration_chain( info.txn_idx, )?; if let Some(updated) = reg_chain.update(reg.clone()) { - // If the registration being update is not problematic - // It can be added to the registration chain - if !reg.report().is_problematic() { - reg_chain = updated; // Broken registration in the chain doesn't break the early created chain, // there is no need to continue the chain since the data after a broken // registration should be ignored - } else { + if reg.report().is_problematic() { return Ok(Some(reg_chain)); } + // If the registration being update is not problematic + // It can be added to the registration chain + reg_chain = updated; } } Ok(Some(reg_chain)) @@ -102,13 +100,11 @@ fn get_registration( anyhow::anyhow!(err) })?; - match Cip509::new(&block, txn_idx.into(), &[]) { - Ok(Some(r)) => Ok(r), - // Expect a registration, so treat None as an error - Ok(None) | Err(_) => { - let err = format!("Failed to get registration at slot {slot_no}"); - log_error(file!(), func_name, "Cip509::new", &err, None); - anyhow::bail!(err) - }, + if let Ok(Some(r)) = Cip509::new(&block, txn_idx.into(), &[]) { + Ok(r) + } else { + let err = format!("Failed to get registration at slot {slot_no}"); + log_error(file!(), func_name, "Cip509::new", &err, None); + anyhow::bail!(err) } } diff --git a/hermes/apps/athena/modules/auth/src/rbac/get_rbac.rs b/hermes/apps/athena/modules/auth/src/rbac/get_rbac.rs index 3975f01ee1..64f6c4e25c 100644 --- a/hermes/apps/athena/modules/auth/src/rbac/get_rbac.rs +++ b/hermes/apps/athena/modules/auth/src/rbac/get_rbac.rs @@ -22,7 +22,7 @@ pub(crate) fn get_rbac_chain_from_cat_id( ) -> anyhow::Result> { let (reg_locations, metadata) = select_rbac_registration_chain_from_cat_id(persistent, volatile, &cat_id.to_string())?; - let reg_chain = build_registration_chain(network, network_resource, reg_locations)?; + let reg_chain = build_registration_chain(network, network_resource, ®_locations)?; if reg_chain.is_none() { return Ok(None); } diff --git a/hermes/apps/athena/modules/auth/src/rbac/rbac_chain_metadata.rs b/hermes/apps/athena/modules/auth/src/rbac/rbac_chain_metadata.rs index 68afbec5a9..fb3c63023f 100644 --- a/hermes/apps/athena/modules/auth/src/rbac/rbac_chain_metadata.rs +++ b/hermes/apps/athena/modules/auth/src/rbac/rbac_chain_metadata.rs @@ -3,6 +3,7 @@ use cardano_blockchain_types::{hashes::TransactionId, Slot}; /// RBAC chain metadata. +#[allow(clippy::struct_field_names)] #[derive(Debug, Clone, Default)] pub(crate) struct RbacChainMetadata { /// Last persistent transaction. diff --git a/hermes/apps/athena/modules/auth/src/utils.rs b/hermes/apps/athena/modules/auth/src/utils.rs index 45d32b1ee0..d77eec65d0 100644 --- a/hermes/apps/athena/modules/auth/src/utils.rs +++ b/hermes/apps/athena/modules/auth/src/utils.rs @@ -23,11 +23,13 @@ macro_rules! extract_header { } #[cfg(test)] +#[allow(clippy::needless_pass_by_value)] mod tests { + use shared::bindings::hermes::http_gateway::api::Headers; use test_case::test_case; #[test_case( - vec![ + &vec![ ("content-type".to_string(), vec!["application/json".to_string()]), ("authorization".to_string(), vec!["Bearer catid.123@test.com/signature".to_string()]), ], @@ -36,7 +38,7 @@ mod tests { ; "extract header without prefix" )] fn test_extract_header_without_prefix( - headers: Vec<(String, Vec)>, + headers: &Headers, header_name: &str, expected: Option, ) { @@ -45,7 +47,7 @@ mod tests { } #[test_case( - vec![ + &vec![ ("content-type".to_string(), vec!["application/json".to_string()]), ("authorization".to_string(), vec!["Bearer catid.123@test.com/signature".to_string()]), ], @@ -55,7 +57,7 @@ mod tests { ; "extract header with Bearer prefix - should strip the prefix" )] #[test_case( - vec![ + &vec![ ("content-type".to_string(), vec!["application/json".to_string()]), ("authorization".to_string(), vec!["Basic dXYXNz".to_string()]), ], @@ -65,21 +67,21 @@ mod tests { ; "extract header with Bearer prefix when header has Basic auth" )] #[test_case( - vec![("Authorization".to_string(), vec!["Bearer catid.123@test.com/signature".to_string()])], + &vec![("Authorization".to_string(), vec!["Bearer catid.123@test.com/signature".to_string()])], "authorization", "Bearer ", Some("catid.123@test.com/signature".to_string()) ; "case insensitive header name matching" )] #[test_case( - vec![("content-type".to_string(), vec!["application/json".to_string()])], + &vec![("content-type".to_string(), vec!["application/json".to_string()])], "authorization", "Bearer ", None ; "extract non-existent header" )] #[test_case( - vec![("authorization".to_string(), vec![ + &vec![("authorization".to_string(), vec![ "Bearer catid.123@test.com/signature".to_string(), "Bearer catid.456@test.com/signature2".to_string(), ])], @@ -89,7 +91,7 @@ mod tests { ; "extract header with multiple values - should return first match" )] fn test_extract_header_with_prefix( - headers: Vec<(String, Vec)>, + headers: &Headers, header_name: &str, prefix: &str, expected: Option, diff --git a/hermes/apps/athena/modules/auth/src/validation.rs b/hermes/apps/athena/modules/auth/src/validation.rs index c0fc1713ce..1e8d80ac6c 100644 --- a/hermes/apps/athena/modules/auth/src/validation.rs +++ b/hermes/apps/athena/modules/auth/src/validation.rs @@ -24,7 +24,7 @@ const MAX_TOKEN_SKEW: Duration = Duration::from_secs(5 * 60); // 5 minutes /// [here]: https://github.com/input-output-hk/catalyst-voices/blob/main/docs/src/catalyst-standards/permissionless-auth/auth-header.md#backend-processing-of-the-token pub fn checker_api_catalyst_auth( - headers: Headers, + headers: &Headers, bearer_token: &str, network: cardano::api::CardanoNetwork, ) -> AuthResponse { diff --git a/hermes/bin/src/runtime_extensions/hermes/http_gateway/gateway_task.rs b/hermes/bin/src/runtime_extensions/hermes/http_gateway/gateway_task.rs index a13a2f4a4f..cff6777178 100644 --- a/hermes/bin/src/runtime_extensions/hermes/http_gateway/gateway_task.rs +++ b/hermes/bin/src/runtime_extensions/hermes/http_gateway/gateway_task.rs @@ -45,8 +45,7 @@ impl Default for Config { ] .to_vec(), local_addr: SocketAddr::new([127, 0, 0, 1].into(), GATEWAY_PORT), - // TODO(bkioshn): change back when auth module is added - is_auth_activate: false, + is_auth_activate: true, } } } From 1fac9405bb2c186acc1a02773574973154f6b17b Mon Sep 17 00:00:00 2001 From: bkioshn Date: Thu, 13 Nov 2025 09:50:11 +0700 Subject: [PATCH 23/24] move header to shared Signed-off-by: bkioshn --- hermes/apps/athena/modules/auth/src/api_keys.rs | 3 ++- hermes/apps/athena/modules/auth/src/lib.rs | 3 +-- hermes/apps/athena/shared/Cargo.toml | 3 +++ .../auth/src/utils.rs => shared/src/utils/common/header.rs} | 3 ++- hermes/apps/athena/shared/src/utils/common/mod.rs | 1 + 5 files changed, 9 insertions(+), 4 deletions(-) rename hermes/apps/athena/{modules/auth/src/utils.rs => shared/src/utils/common/header.rs} (98%) diff --git a/hermes/apps/athena/modules/auth/src/api_keys.rs b/hermes/apps/athena/modules/auth/src/api_keys.rs index d6bc1a6100..252153acd3 100644 --- a/hermes/apps/athena/modules/auth/src/api_keys.rs +++ b/hermes/apps/athena/modules/auth/src/api_keys.rs @@ -7,8 +7,9 @@ use std::env; use anyhow::{bail, Result}; +use shared::extract_header; -use crate::{extract_header, hermes::http_gateway::api::Headers}; +use crate::hermes::http_gateway::api::Headers; /// The header name that holds the API Key pub(crate) const API_KEY_HEADER: &str = "X-API-Key"; diff --git a/hermes/apps/athena/modules/auth/src/lib.rs b/hermes/apps/athena/modules/auth/src/lib.rs index 3da5520f8c..319e8af205 100644 --- a/hermes/apps/athena/modules/auth/src/lib.rs +++ b/hermes/apps/athena/modules/auth/src/lib.rs @@ -23,10 +23,9 @@ mod database; mod rbac; mod response; mod token; -mod utils; mod validation; -use shared::{bindings::hermes::cardano, utils::log}; +use shared::{bindings::hermes::cardano, extract_header, utils::log}; use crate::{ hermes::http_gateway::api::{AuthRequest, Bstr, Headers, HttpResponse}, diff --git a/hermes/apps/athena/shared/Cargo.toml b/hermes/apps/athena/shared/Cargo.toml index aab490268c..bbeba0c78b 100644 --- a/hermes/apps/athena/shared/Cargo.toml +++ b/hermes/apps/athena/shared/Cargo.toml @@ -82,3 +82,6 @@ catalyst-types = { version = "0.0.10", git = "https://github.com/input-output-hk [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] rusqlite = { version = "0.37.0", features = ["bundled"] } + +[dev-dependencies] +test-case = "3.3.1" \ No newline at end of file diff --git a/hermes/apps/athena/modules/auth/src/utils.rs b/hermes/apps/athena/shared/src/utils/common/header.rs similarity index 98% rename from hermes/apps/athena/modules/auth/src/utils.rs rename to hermes/apps/athena/shared/src/utils/common/header.rs index d77eec65d0..58b306376f 100644 --- a/hermes/apps/athena/modules/auth/src/utils.rs +++ b/hermes/apps/athena/shared/src/utils/common/header.rs @@ -25,9 +25,10 @@ macro_rules! extract_header { #[cfg(test)] #[allow(clippy::needless_pass_by_value)] mod tests { - use shared::bindings::hermes::http_gateway::api::Headers; use test_case::test_case; + use crate::bindings::hermes::http_gateway::api::Headers; + #[test_case( &vec![ ("content-type".to_string(), vec!["application/json".to_string()]), diff --git a/hermes/apps/athena/shared/src/utils/common/mod.rs b/hermes/apps/athena/shared/src/utils/common/mod.rs index 7734e3446c..1ce28b83a6 100644 --- a/hermes/apps/athena/shared/src/utils/common/mod.rs +++ b/hermes/apps/athena/shared/src/utils/common/mod.rs @@ -2,6 +2,7 @@ //! these components should be structured into their own sub modules. pub mod auth; +pub mod header; pub mod objects; pub mod responses; pub mod types; From bfe7268e8acd03f1b35800aebdc5324ec0b9335e Mon Sep 17 00:00:00 2001 From: bkioshn Date: Thu, 13 Nov 2025 14:28:53 +0700 Subject: [PATCH 24/24] move header to shared Signed-off-by: bkioshn --- hermes/apps/athena/shared/src/utils/common/mod.rs | 1 - hermes/apps/athena/shared/src/utils/{common => }/header.rs | 0 hermes/apps/athena/shared/src/utils/mod.rs | 1 + 3 files changed, 1 insertion(+), 1 deletion(-) rename hermes/apps/athena/shared/src/utils/{common => }/header.rs (100%) diff --git a/hermes/apps/athena/shared/src/utils/common/mod.rs b/hermes/apps/athena/shared/src/utils/common/mod.rs index 1ce28b83a6..7734e3446c 100644 --- a/hermes/apps/athena/shared/src/utils/common/mod.rs +++ b/hermes/apps/athena/shared/src/utils/common/mod.rs @@ -2,7 +2,6 @@ //! these components should be structured into their own sub modules. pub mod auth; -pub mod header; pub mod objects; pub mod responses; pub mod types; diff --git a/hermes/apps/athena/shared/src/utils/common/header.rs b/hermes/apps/athena/shared/src/utils/header.rs similarity index 100% rename from hermes/apps/athena/shared/src/utils/common/header.rs rename to hermes/apps/athena/shared/src/utils/header.rs diff --git a/hermes/apps/athena/shared/src/utils/mod.rs b/hermes/apps/athena/shared/src/utils/mod.rs index 0e0b33ab6c..86a16ab8df 100644 --- a/hermes/apps/athena/shared/src/utils/mod.rs +++ b/hermes/apps/athena/shared/src/utils/mod.rs @@ -1,6 +1,7 @@ //! Common functions and types shared by Athena modules. pub mod cardano; +pub mod header; pub mod log; pub mod problem_report; pub mod sqlite;