Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
415 changes: 405 additions & 10 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
[workspace]
members = [
"components/api-server",
"components/clp-credential-manager",
"components/clp-rust-utils",
"components/log-ingestor"
"components/log-ingestor",
]
resolver = "3"
28 changes: 28 additions & 0 deletions components/clp-credential-manager/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "clp-credential-manager"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0.100"
axum = { version = "0.8.6", features = ["json"] }
chrono = { version = "0.4.38", features = ["serde"] }
clap = { version = "4.5.51", features = ["derive"] }
jsonwebtoken = "9.3.0"
secrecy = { version = "0.10.3", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
serde_yaml = "0.9.34"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "mysql", "macros", "chrono"] }
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["full"] }
tower = "0.5.2"
tower-http = { version = "0.5.2", features = ["trace", "cors"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt", "json", "std"] }
uuid = { version = "1.11.1", features = ["v4", "serde"] }
aws-config = "1.5.1"
aws-sdk-sts = "1.48.0"

[dev-dependencies]
reqwest = { version = "0.12.9", features = ["json"] }
113 changes: 113 additions & 0 deletions components/clp-credential-manager/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use std::{fs, net::SocketAddr, path::Path};

use secrecy::SecretString;
use serde::Deserialize;

use crate::error::{ServiceError, ServiceResult};

const DEFAULT_MAX_CONNECTIONS: u32 = 5;
const DEFAULT_SERVER_BIND_ADDRESS: &str = "0.0.0.0";
const DEFAULT_SERVER_PORT: u16 = 8080;

/// Root configuration deserialized from `credential-manager-config.yml`.
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
#[serde(default)]
pub server: ServerConfig,
pub database: DatabaseConfig,
}

impl AppConfig {
/// Loads configuration from disk and validates YAML syntax.
///
/// # Parameters:
///
/// * `path`: Filesystem location of the YAML configuration file.
///
/// # Returns:
///
/// A parsed [`AppConfig`] instance when the file can be read and deserialized successfully.
///
/// # Errors:
///
/// * Returns [`ServiceError::Io`] if the file cannot be read.
/// * Returns [`ServiceError::Yaml`] if parsing fails.
pub fn from_file(path: &Path) -> ServiceResult<Self> {
let contents = fs::read_to_string(path)?;
let config: Self = serde_yaml::from_str(&contents)?;
Ok(config)
}
}

/// Network settings for the Axum server.
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_bind_address")]
pub bind_address: String,
#[serde(default = "default_bind_port")]
pub port: u16,
}

impl ServerConfig {
/// Renders the configured address pair into a [`SocketAddr`].
///
/// # Returns:
///
/// A [`SocketAddr`] that Axum can bind to when starting the HTTP server.
///
/// # Errors:
///
/// Returns [`ServiceError::Config`] if the address string cannot be parsed.
pub fn socket_addr(&self) -> ServiceResult<SocketAddr> {
let addr = format!("{}:{}", self.bind_address, self.port);
addr.parse().map_err(|err| {
ServiceError::Config(format!(
"invalid bind address `{}`:{} ({err})",
self.bind_address, self.port
))
})
}
}

impl Default for ServerConfig {
/// Provides sensible defaults that bind the HTTP server to all interfaces on port 8080.
fn default() -> Self {
Self {
bind_address: default_bind_address(),
port: default_bind_port(),
}
}
}

/// Supplies the default bind address of `0.0.0.0` so the service listens on every interface.
fn default_bind_address() -> String {
DEFAULT_SERVER_BIND_ADDRESS.to_owned()
}

/// Supplies the default bind port of `8080` that matches other CLP services.
const fn default_bind_port() -> u16 {
DEFAULT_SERVER_PORT
}

/// Connection options for the `MySQL` backend that stores credentials.
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
pub host: String,
#[serde(default = "default_mysql_port")]
pub port: u16,
pub name: String,
pub user: String,
pub password: SecretString,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
}

/// Supplies the default `MySQL` server port (`3306`).
const fn default_mysql_port() -> u16 {
3306
}

/// Supplies the default maximum connection count for the `MySQL` pool.
const fn default_max_connections() -> u32 {
DEFAULT_MAX_CONNECTIONS
}
161 changes: 161 additions & 0 deletions components/clp-credential-manager/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#![allow(dead_code)]

use std::fmt;

use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use jsonwebtoken::errors::Error as JwtError;
use serde::Serialize;
use thiserror::Error;

/// Convenience alias for functions that return a [`ServiceError`].
pub type ServiceResult<T> = Result<T, ServiceError>;

/// Canonical error enumeration for the credential manager.
#[derive(Debug, Error)]
pub enum ServiceError {
#[error("configuration error: {0}")]
Config(String),

#[error("validation error: {0}")]
Validation(String),

#[error("database error: {0}")]
Database(#[source] sqlx::Error),

#[error("resource conflict: {0}")]
Conflict(String),

#[error("resource not found: {0}")]
NotFound(String),

#[error("jwt error: {0}")]
Jwt(#[source] JwtError),

#[error("i/o error: {0}")]
Io(#[from] std::io::Error),

#[error("serialization error: {0}")]
Yaml(#[from] serde_yaml::Error),
}

impl From<sqlx::Error> for ServiceError {
/// Maps raw [`sqlx::Error`] values into domain-specific variants so callers can react
/// precisely.
///
/// # Parameters:
///
/// * `err`: The database error surfaced by `sqlx`.
fn from(err: sqlx::Error) -> Self {
if matches!(err, sqlx::Error::RowNotFound) {
return Self::NotFound("requested record was not found".to_owned());
}

if let Some(db_err) = err.as_database_error()
&& let Some(mysql_err) = db_err.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>()
&& mysql_err.number() == 1062
{
return Self::Conflict(mysql_err.message().to_owned());
}

Self::Database(err)
}
}

/// HTTP-friendly representation of service failures.
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
message: String,
}

impl ApiError {
/// Creates a new API error with the supplied HTTP status code.
///
/// # Parameters:
///
/// * `status`: HTTP status code that best represents the failure.
/// * `message`: Human-readable diagnostic message.
///
/// # Returns:
///
/// A new [`ApiError`] ready to be converted into an HTTP response.
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}

/// Convenience constructor for 500-class responses.
///
/// # Parameters:
///
/// * `message`: Human-readable error string for the response body.
///
/// # Returns:
///
/// A new [`ApiError`] that always maps to [`StatusCode::INTERNAL_SERVER_ERROR`].
pub fn internal(message: impl Into<String>) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
}
}

impl From<ServiceError> for ApiError {
fn from(err: ServiceError) -> Self {
match err {
ServiceError::Validation(msg) => Self::new(StatusCode::BAD_REQUEST, msg),
ServiceError::Conflict(msg) => Self::new(StatusCode::CONFLICT, msg),
ServiceError::NotFound(msg) => Self::new(StatusCode::NOT_FOUND, msg),
ServiceError::Config(msg) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, msg),
ServiceError::Database(source) => {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, source.to_string())
}
ServiceError::Jwt(source) => {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, source.to_string())
}
ServiceError::Io(source) => {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, source.to_string())
}
ServiceError::Yaml(source) => {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, source.to_string())
}
}
}
}

/// JSON payload emitted by error responses.
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}

impl IntoResponse for ApiError {
/// Serializes the error payload into JSON schema so clients receive consistent bodies.
///
/// # Returns:
///
/// An [`axum::response::Response`] containing the status code plus `{"error": "..."}` body.
fn into_response(self) -> Response {
let status = self.status;
let body = Json(ErrorBody {
error: self.message,
});

(status, body).into_response()
}
}

impl fmt::Display for ApiError {
/// Formats only the message so higher layers can log without leaking sensitive details.
///
/// # Returns:
///
/// [`fmt::Result`] signaling whether writing to the formatter succeeded.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
68 changes: 68 additions & 0 deletions components/clp-credential-manager/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
mod config;
mod error;
mod routes;
mod service;

use std::path::PathBuf;

use clap::Parser;
use tokio::net::TcpListener;
use tracing::info;
use tracing_subscriber::EnvFilter;

use crate::{config::AppConfig, routes::build_router, service::CredentialManagerService};

/// CLI arguments accepted by the credential manager binary.
#[derive(Debug, Parser)]
#[command(author, version, about = "CLP Credential Manager service", long_about = None)]
struct Args {
#[arg(
short = 'c',
long = "config",
value_name = "FILE",
default_value = "credential-manager-config.yml"
)]
config: PathBuf,
}

/// Binary entry point that configures logging, loads config, and starts Axum.
///
/// # Errors:
///
/// Returns [`anyhow::Error`] when configuration parsing, database connections, TCP binding,
/// or Axum startup fail.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();

init_tracing();

let config = AppConfig::from_file(&args.config)?;
let server_config = config.server.clone();
let service = CredentialManagerService::new(&config).await?;
let shared_service = service.clone_shared();

let router = build_router().with_state(shared_service);

let addr = server_config.socket_addr()?;
info!(address = %addr, "starting credential manager service");

let listener = TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;

Ok(())
}

/// Sets up JSON-formatted tracing using environment filters.
///
/// The subscriber honors the `RUST_LOG` environment variable when present and otherwise
/// defaults to the `info` level so local development remains verbose enough.
fn init_tracing() {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

let _ = tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(false)
.json()
.try_init();
}
Loading
Loading