use ahash::AHashMap as HashMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("API key is required when admin API is enabled")]
ApiKeyRequired,
#[error("API key must be at least 16 characters, got {0}")]
ApiKeyTooShort(usize),
}
impl crate::i18n::LocalizedMsg for ConfigError {
fn message_key(&self) -> &'static str {
match self {
ConfigError::ApiKeyRequired => "admin-config-api-key-required",
ConfigError::ApiKeyTooShort(_) => "admin-config-api-key-too-short",
}
}
fn message_args(&self) -> Vec<(&'static str, String)> {
match self {
ConfigError::ApiKeyRequired => vec![],
ConfigError::ApiKeyTooShort(length) => vec![("length", length.to_string())],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AdminRole {
Admin,
Viewer,
}
impl AdminRole {
pub fn as_str(&self) -> &'static str {
match self {
AdminRole::Admin => "admin",
AdminRole::Viewer => "viewer",
}
}
pub fn allows_read(&self) -> bool {
true
}
pub fn allows_write(&self) -> bool {
matches!(self, AdminRole::Admin)
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminApiConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
pub api_key: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub api_key_operators: HashMap<String, String>,
#[serde(default)]
pub api_key_roles: HashMap<String, AdminRole>,
#[serde(default = "default_rate_limits")]
pub rate_limits: HashMap<String, (u64, u64)>,
}
impl std::fmt::Debug for AdminApiConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AdminApiConfig")
.field("host", &self.host)
.field("port", &self.port)
.field(
"api_key",
&format!("<redacted; {} chars>", self.api_key.len()),
)
.field("enabled", &self.enabled)
.field("api_key_operators", &self.api_key_operators)
.field("api_key_roles", &self.api_key_roles)
.field("rate_limits", &self.rate_limits)
.finish()
}
}
fn default_rate_limits() -> HashMap<String, (u64, u64)> {
let mut m = HashMap::new();
m.insert("ban".to_string(), (100, 60));
m.insert("quota".to_string(), (50, 60));
m.insert("default".to_string(), (200, 60));
m
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
9090
}
fn default_enabled() -> bool {
false
}
impl Default for AdminApiConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
api_key: String::new(),
enabled: default_enabled(),
api_key_operators: HashMap::new(),
api_key_roles: HashMap::new(),
rate_limits: default_rate_limits(),
}
}
}
impl AdminApiConfig {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
host: default_host(),
port: default_port(),
api_key: api_key.into(),
enabled: true,
api_key_operators: HashMap::new(),
api_key_roles: HashMap::new(),
rate_limits: default_rate_limits(),
}
}
pub fn with_host(mut self, host: impl Into<String>) -> Self {
self.host = host.into();
self
}
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = api_key.into();
self
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn with_api_key_operator(
mut self,
api_key: impl Into<String>,
operator: impl Into<String>,
) -> Self {
self.api_key_operators
.insert(api_key.into(), operator.into());
self
}
pub fn with_api_key_operators(mut self, mapping: HashMap<String, String>) -> Self {
self.api_key_operators = mapping;
self
}
pub fn with_api_key_role(mut self, api_key: impl Into<String>, role: AdminRole) -> Self {
self.api_key_roles.insert(api_key.into(), role);
self
}
pub fn role_for_api_key(&self, api_key: &str) -> AdminRole {
self.api_key_roles
.get(api_key)
.copied()
.unwrap_or(AdminRole::Admin)
}
pub fn is_valid_api_key(&self, api_key: &str) -> bool {
api_key == self.api_key || self.api_key_roles.contains_key(api_key)
}
pub fn operator_for_api_key(&self, api_key: &str) -> Option<&str> {
self.api_key_operators.get(api_key).map(|s| s.as_str())
}
pub fn with_rate_limit(mut self, group: &str, max: u64, window_secs: u64) -> Self {
self.rate_limits
.insert(group.to_string(), (max, window_secs));
self
}
pub fn address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.enabled && self.api_key.is_empty() {
return Err(ConfigError::ApiKeyRequired);
}
if !self.api_key.is_empty() && self.api_key.len() < 16 {
return Err(ConfigError::ApiKeyTooShort(self.api_key.len()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_values() {
let config = AdminApiConfig::default();
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.port, 9090);
assert!(config.api_key.is_empty());
assert!(!config.enabled);
}
#[test]
fn test_new_sets_api_key_and_enabled() {
let config = AdminApiConfig::new("my-secure-api-key-32chars");
assert_eq!(config.api_key, "my-secure-api-key-32chars");
assert!(config.enabled);
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.port, 9090);
}
#[test]
fn test_builder_methods() {
let config = AdminApiConfig::new("initial-api-key-32chars!!")
.with_host("0.0.0.0")
.with_port(8080)
.with_api_key("replacement-api-key-32ch")
.with_enabled(false);
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 8080);
assert_eq!(config.api_key, "replacement-api-key-32ch");
assert!(!config.enabled);
}
#[test]
fn test_address_format() {
let config = AdminApiConfig::new("my-secure-api-key-32chars")
.with_host("192.168.1.1")
.with_port(3000);
assert_eq!(config.address(), "192.168.1.1:3000");
}
#[test]
fn test_validate_enabled_empty_key_returns_api_key_required() {
let config = AdminApiConfig {
api_key: String::new(),
enabled: true,
..Default::default()
};
match config.validate() {
Err(ConfigError::ApiKeyRequired) => {}
other => panic!("expected ApiKeyRequired, got {:?}", other),
}
}
#[test]
fn test_validate_short_key_returns_too_short_with_length() {
let config = AdminApiConfig {
api_key: "short".to_string(),
enabled: true,
..Default::default()
};
match config.validate() {
Err(ConfigError::ApiKeyTooShort(5)) => {}
other => panic!("expected ApiKeyTooShort(5), got {:?}", other),
}
}
#[test]
fn test_validate_disabled_empty_key_ok() {
let config = AdminApiConfig {
api_key: String::new(),
enabled: false,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_valid_config_ok() {
let config = AdminApiConfig::new("this-is-a-valid-api-key-32ch");
assert!(config.validate().is_ok());
}
#[test]
fn test_config_error_display_messages() {
assert_eq!(
ConfigError::ApiKeyRequired.to_string(),
"API key is required when admin API is enabled"
);
assert_eq!(
ConfigError::ApiKeyTooShort(10).to_string(),
"API key must be at least 16 characters, got 10"
);
}
#[test]
fn test_default_config_has_empty_api_key_operators() {
let config = AdminApiConfig::default();
assert!(config.api_key_operators.is_empty());
}
#[test]
fn test_with_api_key_operator_adds_mapping() {
let config = AdminApiConfig::new("my-secure-api-key-32chars")
.with_api_key_operator("my-secure-api-key-32chars", "admin-alice");
assert_eq!(
config.operator_for_api_key("my-secure-api-key-32chars"),
Some("admin-alice")
);
}
#[test]
fn test_with_api_key_operators_replaces_mapping() {
let mut mapping = HashMap::new();
mapping.insert("key-a-16chars-long".to_string(), "admin-a".to_string());
mapping.insert("key-b-16chars-long".to_string(), "admin-b".to_string());
let config = AdminApiConfig::new("primary-key-16chars!!!").with_api_key_operators(mapping);
assert_eq!(
config.operator_for_api_key("key-a-16chars-long"),
Some("admin-a")
);
assert_eq!(
config.operator_for_api_key("key-b-16chars-long"),
Some("admin-b")
);
assert_eq!(config.operator_for_api_key("primary-key-16chars!!!"), None);
}
#[test]
fn test_operator_for_api_key_returns_none_when_not_configured() {
let config = AdminApiConfig::new("my-secure-api-key-32chars");
assert_eq!(
config.operator_for_api_key("my-secure-api-key-32chars"),
None
);
}
#[test]
fn test_operator_for_api_key_returns_none_for_unknown_key() {
let config = AdminApiConfig::new("my-secure-api-key-32chars")
.with_api_key_operator("my-secure-api-key-32chars", "admin-alice");
assert_eq!(config.operator_for_api_key("unknown-key"), None);
}
}
#[cfg(test)]
mod t605_role_tests {
use super::*;
#[test]
fn test_t605_role_for_api_key_defaults_to_admin() {
let config = AdminApiConfig::new("primary-key-16chars!!!");
assert_eq!(
config.role_for_api_key("primary-key-16chars!!!"),
AdminRole::Admin
);
assert_eq!(config.role_for_api_key("anything"), AdminRole::Admin);
}
#[test]
fn test_t605_with_api_key_role_maps_viewer() {
let config = AdminApiConfig::new("primary-key-16chars!!!")
.with_api_key_role("viewer-key-16chars!!", AdminRole::Viewer);
assert_eq!(
config.role_for_api_key("viewer-key-16chars!!"),
AdminRole::Viewer
);
assert_eq!(
config.role_for_api_key("primary-key-16chars!!!"),
AdminRole::Admin
);
}
#[test]
fn test_t605_is_valid_api_key_accepts_registered_keys() {
let config = AdminApiConfig::new("primary-key-16chars!!!")
.with_api_key_role("viewer-key-16chars!!", AdminRole::Viewer);
assert!(config.is_valid_api_key("primary-key-16chars!!!"));
assert!(config.is_valid_api_key("viewer-key-16chars!!"));
assert!(!config.is_valid_api_key("rogue-key"));
}
#[test]
fn test_t605_role_permissions_matrix() {
assert!(AdminRole::Viewer.allows_read());
assert!(!AdminRole::Viewer.allows_write());
assert!(AdminRole::Admin.allows_read());
assert!(AdminRole::Admin.allows_write());
}
#[test]
fn test_t605_role_serde_lowercase() {
assert_eq!(
serde_json::to_string(&AdminRole::Viewer).unwrap(),
"\"viewer\""
);
assert_eq!(
serde_json::to_string(&AdminRole::Admin).unwrap(),
"\"admin\""
);
let role: AdminRole = serde_json::from_str("\"viewer\"").unwrap();
assert_eq!(role, AdminRole::Viewer);
}
}