use std::env;
use std::fs;
use std::time::Duration;
use anyhow::{Context, Result};
use bytes::Bytes;
use regex::Regex;
use resp_async::ServerConfig;
use serde::Deserialize;
const DEFAULT_BIND_ADDR: &str = "0.0.0.0:6380";
const DEFAULT_MYSQL_ADDR: &str = "127.0.0.1:3306";
const DEFAULT_MYSQL_DATABASE: &str = "redis";
const DEFAULT_TENANT_REGEX: &str = "^(?P<tenant>[^:]+):";
const DEFAULT_TENANT_NO_MATCH: &str = "username";
const DEFAULT_POOL_MAX_CONNECTIONS: u32 = 16;
const DEFAULT_POOL_CONNECT_TIMEOUT_MS: u64 = 5000;
const DEFAULT_POOL_IDLE_TTL_SECS: u64 = 600;
const DEFAULT_EXPIRE_CLEANUP_INTERVAL_SECS: u64 = 60;
const DEFAULT_EXPIRE_CLEANUP_BATCH: u64 = 1000;
const DEFAULT_PUBSUB_CLEANUP_INTERVAL_SECS: u64 = 60;
const DEFAULT_PUBSUB_IDLE_SECS: u64 = 600;
const DEFAULT_PUBSUB_MESSAGE_TTL_SECS: u64 = 3600;
const DEFAULT_PUBSUB_POLL_MS: u64 = 200;
const DEFAULT_PUBSUB_POLL_BATCH: u64 = 128;
const DEFAULT_PUBSUB_HEARTBEAT_SECS: u64 = 30;
const DEFAULT_SET_RETRY_MAX_ATTEMPTS: usize = 3;
const DEFAULT_SET_RETRY_BACKOFF_BASE_MS: u64 = 1;
const DEFAULT_SET_RETRY_BACKOFF_MAX_MS: u64 = 16;
const DEFAULT_INCR_RETRY_MAX_ATTEMPTS: usize = 64;
const DEFAULT_INCR_RETRY_BACKOFF_BASE_MS: u64 = 1;
const DEFAULT_INCR_RETRY_BACKOFF_MAX_MS: u64 = 16;
const DEFAULT_INCR_RETRY_DEADLINE_MS: u64 = 2000;
#[derive(Clone)]
pub struct ProxyConfig {
pub bind_addr: String,
pub mysql_addr: String,
pub mysql_database: String,
pub mysql_options: Option<String>,
pub auth: AuthConfig,
pub pool: PoolConfig,
pub cleanup: CleanupConfig,
pub pubsub: PubSubConfig,
pub string_set_retry: RetryConfig,
pub string_incr_retry: RetryConfig,
pub server: ServerConfig,
}
#[derive(Clone)]
pub struct AuthConfig {
tenant_regex: Regex,
tenant_policy: TenantNoMatchPolicy,
tenant_literal: Bytes,
}
#[derive(Clone, Copy, Debug)]
pub enum TenantNoMatchPolicy {
Reject,
Username,
Literal,
}
#[derive(Clone, Debug)]
pub struct PoolConfig {
pub max_connections: u32,
pub connect_timeout: Duration,
pub idle_ttl: Duration,
}
#[derive(Clone, Debug)]
pub struct CleanupConfig {
pub expire_interval: Duration,
pub expire_batch: u64,
pub pubsub_interval: Duration,
pub pubsub_idle_ttl: Duration,
pub pubsub_message_ttl: Duration,
}
#[derive(Clone, Debug)]
pub struct PubSubConfig {
pub poll_interval: Duration,
pub poll_batch: u64,
pub heartbeat_interval: Duration,
}
#[derive(Clone, Debug)]
pub struct RetryConfig {
pub max_attempts: usize,
pub backoff_base_ms: u64,
pub backoff_max_ms: u64,
pub deadline_ms: Option<u64>,
}
#[derive(Debug)]
pub enum TenantError {
NoMatch,
}
impl ProxyConfig {
pub fn load() -> Result<Self> {
let mut cfg = Self::defaults()?;
if let Some(file_cfg) = load_file_config()? {
cfg.apply_file(file_cfg)?;
}
cfg.apply_env_overrides()?;
Ok(cfg)
}
fn defaults() -> Result<Self> {
let auth = AuthConfig::new(DEFAULT_TENANT_REGEX, DEFAULT_TENANT_NO_MATCH)?;
Ok(Self {
bind_addr: DEFAULT_BIND_ADDR.to_string(),
mysql_addr: DEFAULT_MYSQL_ADDR.to_string(),
mysql_database: DEFAULT_MYSQL_DATABASE.to_string(),
mysql_options: None,
auth,
pool: PoolConfig {
max_connections: DEFAULT_POOL_MAX_CONNECTIONS,
connect_timeout: Duration::from_millis(DEFAULT_POOL_CONNECT_TIMEOUT_MS),
idle_ttl: Duration::from_secs(DEFAULT_POOL_IDLE_TTL_SECS),
},
cleanup: CleanupConfig {
expire_interval: Duration::from_secs(DEFAULT_EXPIRE_CLEANUP_INTERVAL_SECS),
expire_batch: DEFAULT_EXPIRE_CLEANUP_BATCH,
pubsub_interval: Duration::from_secs(DEFAULT_PUBSUB_CLEANUP_INTERVAL_SECS),
pubsub_idle_ttl: Duration::from_secs(DEFAULT_PUBSUB_IDLE_SECS),
pubsub_message_ttl: Duration::from_secs(DEFAULT_PUBSUB_MESSAGE_TTL_SECS),
},
pubsub: PubSubConfig {
poll_interval: Duration::from_millis(DEFAULT_PUBSUB_POLL_MS),
poll_batch: DEFAULT_PUBSUB_POLL_BATCH,
heartbeat_interval: Duration::from_secs(DEFAULT_PUBSUB_HEARTBEAT_SECS),
},
string_set_retry: RetryConfig {
max_attempts: DEFAULT_SET_RETRY_MAX_ATTEMPTS,
backoff_base_ms: DEFAULT_SET_RETRY_BACKOFF_BASE_MS,
backoff_max_ms: DEFAULT_SET_RETRY_BACKOFF_MAX_MS,
deadline_ms: None,
},
string_incr_retry: RetryConfig {
max_attempts: DEFAULT_INCR_RETRY_MAX_ATTEMPTS,
backoff_base_ms: DEFAULT_INCR_RETRY_BACKOFF_BASE_MS,
backoff_max_ms: DEFAULT_INCR_RETRY_BACKOFF_MAX_MS,
deadline_ms: Some(DEFAULT_INCR_RETRY_DEADLINE_MS),
},
server: ServerConfig::default(),
})
}
fn apply_env_overrides(&mut self) -> Result<()> {
if let Some(value) = env_string_opt("REDIS_MYSQL_BIND")? {
self.bind_addr = value;
}
if let Some(value) = env_string_opt("REDIS_MYSQL_ADDR")? {
self.mysql_addr = value;
}
if let Some(value) = env_string_opt("REDIS_MYSQL_DATABASE")? {
self.mysql_database = value;
}
if let Some(value) = env_string_opt("REDIS_MYSQL_DSN_OPTIONS")? {
self.mysql_options = normalize_optional_string(Some(value));
}
let tenant_pattern = env_string_opt("REDIS_MYSQL_TENANT_REGEX")?;
let tenant_policy = env_string_opt("REDIS_MYSQL_TENANT_NO_MATCH")?;
if tenant_pattern.is_some() || tenant_policy.is_some() {
self.refresh_auth(tenant_pattern, tenant_policy)?;
}
if let Some(value) = env_u32_opt("REDIS_MYSQL_POOL_MAX_CONNECTIONS")? {
self.pool.max_connections = value;
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_POOL_CONNECT_TIMEOUT_MS")? {
self.pool.connect_timeout = Duration::from_millis(value);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_POOL_IDLE_TTL_SECS")? {
self.pool.idle_ttl = Duration::from_secs(value);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_EXPIRE_CLEANUP_INTERVAL_SECS")? {
self.cleanup.expire_interval = Duration::from_secs(value);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_EXPIRE_CLEANUP_BATCH")? {
self.cleanup.expire_batch = value;
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_PUBSUB_CLEANUP_INTERVAL_SECS")? {
self.cleanup.pubsub_interval = Duration::from_secs(value);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_PUBSUB_IDLE_SECS")? {
self.cleanup.pubsub_idle_ttl = Duration::from_secs(value);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_PUBSUB_MESSAGE_TTL_SECS")? {
self.cleanup.pubsub_message_ttl = Duration::from_secs(value);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_PUBSUB_POLL_MS")? {
self.pubsub.poll_interval = Duration::from_millis(value);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_PUBSUB_POLL_BATCH")? {
self.pubsub.poll_batch = value;
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_PUBSUB_HEARTBEAT_SECS")? {
self.pubsub.heartbeat_interval = Duration::from_secs(value);
}
if let Some(value) = env_usize("REDIS_MYSQL_SET_RETRY_MAX_ATTEMPTS")? {
self.string_set_retry.max_attempts = value.max(1);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_SET_RETRY_BACKOFF_BASE_MS")? {
self.string_set_retry.backoff_base_ms = value.max(1);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_SET_RETRY_BACKOFF_MAX_MS")? {
self.string_set_retry.backoff_max_ms = value.max(1);
}
if let Some(value) = env_usize("REDIS_MYSQL_INCR_RETRY_MAX_ATTEMPTS")? {
self.string_incr_retry.max_attempts = value.max(1);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_INCR_RETRY_BACKOFF_BASE_MS")? {
self.string_incr_retry.backoff_base_ms = value.max(1);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_INCR_RETRY_BACKOFF_MAX_MS")? {
self.string_incr_retry.backoff_max_ms = value.max(1);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_INCR_RETRY_DEADLINE_MS")? {
self.string_incr_retry.deadline_ms = Some(value.max(1));
}
apply_server_env_overrides(&mut self.server)?;
Ok(())
}
fn apply_file(&mut self, file_cfg: FileConfig) -> Result<()> {
if let Some(value) = file_cfg.bind_addr {
self.bind_addr = value;
}
if let Some(value) = file_cfg.mysql_addr {
self.mysql_addr = value;
}
if let Some(value) = file_cfg.mysql_database {
self.mysql_database = value;
}
if let Some(value) = file_cfg.mysql_options {
self.mysql_options = normalize_optional_string(Some(value));
}
if let Some(auth) = file_cfg.auth {
self.refresh_auth(auth.tenant_regex, auth.tenant_no_match)?;
}
if let Some(pool) = file_cfg.pool {
if let Some(value) = pool.max_connections {
self.pool.max_connections = value;
}
if let Some(value) = pool.connect_timeout_ms {
self.pool.connect_timeout = Duration::from_millis(value);
}
if let Some(value) = pool.idle_ttl_secs {
self.pool.idle_ttl = Duration::from_secs(value);
}
}
if let Some(cleanup) = file_cfg.cleanup {
if let Some(value) = cleanup.expire_cleanup_interval_secs {
self.cleanup.expire_interval = Duration::from_secs(value);
}
if let Some(value) = cleanup.expire_cleanup_batch {
self.cleanup.expire_batch = value;
}
if let Some(value) = cleanup.pubsub_cleanup_interval_secs {
self.cleanup.pubsub_interval = Duration::from_secs(value);
}
if let Some(value) = cleanup.pubsub_idle_secs {
self.cleanup.pubsub_idle_ttl = Duration::from_secs(value);
}
if let Some(value) = cleanup.pubsub_message_ttl_secs {
self.cleanup.pubsub_message_ttl = Duration::from_secs(value);
}
}
if let Some(pubsub) = file_cfg.pubsub {
if let Some(value) = pubsub.poll_ms {
self.pubsub.poll_interval = Duration::from_millis(value);
}
if let Some(value) = pubsub.poll_batch {
self.pubsub.poll_batch = value;
}
if let Some(value) = pubsub.heartbeat_secs {
self.pubsub.heartbeat_interval = Duration::from_secs(value);
}
}
if let Some(retry) = file_cfg.string_set_retry {
if let Some(value) = retry.max_attempts {
self.string_set_retry.max_attempts = value.max(1);
}
if let Some(value) = retry.backoff_base_ms {
self.string_set_retry.backoff_base_ms = value.max(1);
}
if let Some(value) = retry.backoff_max_ms {
self.string_set_retry.backoff_max_ms = value.max(1);
}
if let Some(value) = retry.deadline_ms {
self.string_set_retry.deadline_ms = Some(value.max(1));
}
}
if let Some(retry) = file_cfg.string_incr_retry {
if let Some(value) = retry.max_attempts {
self.string_incr_retry.max_attempts = value.max(1);
}
if let Some(value) = retry.backoff_base_ms {
self.string_incr_retry.backoff_base_ms = value.max(1);
}
if let Some(value) = retry.backoff_max_ms {
self.string_incr_retry.backoff_max_ms = value.max(1);
}
if let Some(value) = retry.deadline_ms {
self.string_incr_retry.deadline_ms = Some(value.max(1));
}
}
if let Some(server) = file_cfg.server {
apply_server_file_overrides(&mut self.server, server);
}
Ok(())
}
fn refresh_auth(
&mut self,
tenant_pattern: Option<String>,
tenant_policy: Option<String>,
) -> Result<()> {
let current_pattern = self.auth.tenant_regex.as_str().to_string();
let current_policy = tenant_policy_name(self.auth.tenant_policy).to_string();
let pattern = tenant_pattern.unwrap_or(current_pattern);
let policy = tenant_policy.unwrap_or(current_policy);
self.auth = AuthConfig::new(&pattern, &policy)?;
Ok(())
}
pub fn mysql_dsn(&self, user: &str, password: &str) -> String {
let mut dsn = format!(
"mysql://{}:{}@{}/{}",
user, password, self.mysql_addr, self.mysql_database
);
if let Some(opts) = &self.mysql_options
&& !opts.is_empty()
{
dsn.push('?');
dsn.push_str(opts);
}
dsn
}
}
impl AuthConfig {
pub fn new(pattern: &str, policy: &str) -> Result<Self> {
let tenant_policy = parse_tenant_policy(policy)?;
let tenant_regex =
Regex::new(pattern).with_context(|| format!("invalid tenant regex: {}", pattern))?;
Ok(Self {
tenant_regex,
tenant_policy,
tenant_literal: Bytes::copy_from_slice(pattern.as_bytes()),
})
}
pub fn tenant_id(&self, username: &str) -> Result<Bytes, TenantError> {
if let Some(caps) = self.tenant_regex.captures(username)
&& let Some(m) = caps.name("tenant").or_else(|| caps.get(1))
{
return Ok(Bytes::copy_from_slice(m.as_str().as_bytes()));
}
match self.tenant_policy {
TenantNoMatchPolicy::Reject => Err(TenantError::NoMatch),
TenantNoMatchPolicy::Username => Ok(Bytes::copy_from_slice(username.as_bytes())),
TenantNoMatchPolicy::Literal => Ok(self.tenant_literal.clone()),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct FileConfig {
bind_addr: Option<String>,
mysql_addr: Option<String>,
mysql_database: Option<String>,
mysql_options: Option<String>,
auth: Option<AuthFileConfig>,
pool: Option<PoolFileConfig>,
cleanup: Option<CleanupFileConfig>,
pubsub: Option<PubSubFileConfig>,
string_set_retry: Option<RetryFileConfig>,
string_incr_retry: Option<RetryFileConfig>,
server: Option<ServerConfigFile>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct AuthFileConfig {
tenant_regex: Option<String>,
tenant_no_match: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct PoolFileConfig {
max_connections: Option<u32>,
connect_timeout_ms: Option<u64>,
idle_ttl_secs: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct CleanupFileConfig {
expire_cleanup_interval_secs: Option<u64>,
expire_cleanup_batch: Option<u64>,
pubsub_cleanup_interval_secs: Option<u64>,
pubsub_idle_secs: Option<u64>,
pubsub_message_ttl_secs: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct PubSubFileConfig {
poll_ms: Option<u64>,
poll_batch: Option<u64>,
heartbeat_secs: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct RetryFileConfig {
max_attempts: Option<usize>,
backoff_base_ms: Option<u64>,
backoff_max_ms: Option<u64>,
deadline_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct ServerConfigFile {
max_frame_size: Option<usize>,
max_bulk_len: Option<usize>,
max_array_len: Option<usize>,
max_depth: Option<usize>,
max_inflight: Option<usize>,
max_connections: Option<usize>,
read_timeout_ms: Option<u64>,
write_timeout_ms: Option<u64>,
idle_timeout_ms: Option<u64>,
push_queue_len: Option<usize>,
response_queue_len: Option<usize>,
write_batch_bytes: Option<usize>,
tcp_nodelay: Option<bool>,
backlog: Option<u32>,
}
fn load_file_config() -> Result<Option<FileConfig>> {
let Some(path) = env_string_opt("REDIS_MYSQL_CONFIG")? else {
return Ok(None);
};
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read config file: {}", path))?;
let cfg = serde_yaml::from_str(&raw)
.with_context(|| format!("failed to parse config file: {}", path))?;
Ok(Some(cfg))
}
fn apply_server_env_overrides(cfg: &mut ServerConfig) -> Result<()> {
if let Some(value) = env_usize("REDIS_MYSQL_MAX_FRAME_SIZE")? {
cfg.max_frame_size = value.max(1);
}
if let Some(value) = env_usize("REDIS_MYSQL_MAX_BULK_LEN")? {
cfg.max_bulk_len = value.max(1);
}
if let Some(value) = env_usize("REDIS_MYSQL_MAX_ARRAY_LEN")? {
cfg.max_array_len = value.max(1);
}
if let Some(value) = env_usize("REDIS_MYSQL_MAX_DEPTH")? {
cfg.max_depth = value.max(1);
}
if let Some(value) = env_usize("REDIS_MYSQL_MAX_INFLIGHT")? {
cfg.max_inflight_requests = value.max(1);
}
if let Some(value) = env_usize("REDIS_MYSQL_MAX_CONNECTIONS")? {
cfg.max_connections = value.max(1);
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_READ_TIMEOUT_MS")? {
cfg.read_timeout = Some(Duration::from_millis(value.max(1)));
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_WRITE_TIMEOUT_MS")? {
cfg.write_timeout = Some(Duration::from_millis(value.max(1)));
}
if let Some(value) = env_u64_opt("REDIS_MYSQL_IDLE_TIMEOUT_MS")? {
cfg.idle_timeout = Some(Duration::from_millis(value.max(1)));
}
if let Some(value) = env_usize("REDIS_MYSQL_PUSH_QUEUE_LEN")? {
cfg.push_queue_len = value.max(1);
}
if let Some(value) = env_usize("REDIS_MYSQL_RESPONSE_QUEUE_LEN")? {
cfg.response_queue_len = value.max(1);
}
if let Some(value) = env_usize("REDIS_MYSQL_WRITE_BATCH_BYTES")? {
cfg.write_batch_bytes = value.max(1);
}
if let Some(value) = env_bool("REDIS_MYSQL_TCP_NODELAY")? {
cfg.tcp_nodelay = value;
}
if let Some(value) = env_u32_opt("REDIS_MYSQL_BACKLOG")? {
cfg.backlog = Some(value.max(1));
}
Ok(())
}
fn apply_server_file_overrides(cfg: &mut ServerConfig, file: ServerConfigFile) {
if let Some(value) = file.max_frame_size {
cfg.max_frame_size = value.max(1);
}
if let Some(value) = file.max_bulk_len {
cfg.max_bulk_len = value.max(1);
}
if let Some(value) = file.max_array_len {
cfg.max_array_len = value.max(1);
}
if let Some(value) = file.max_depth {
cfg.max_depth = value.max(1);
}
if let Some(value) = file.max_inflight {
cfg.max_inflight_requests = value.max(1);
}
if let Some(value) = file.max_connections {
cfg.max_connections = value.max(1);
}
if let Some(value) = file.read_timeout_ms {
cfg.read_timeout = Some(Duration::from_millis(value.max(1)));
}
if let Some(value) = file.write_timeout_ms {
cfg.write_timeout = Some(Duration::from_millis(value.max(1)));
}
if let Some(value) = file.idle_timeout_ms {
cfg.idle_timeout = Some(Duration::from_millis(value.max(1)));
}
if let Some(value) = file.push_queue_len {
cfg.push_queue_len = value.max(1);
}
if let Some(value) = file.response_queue_len {
cfg.response_queue_len = value.max(1);
}
if let Some(value) = file.write_batch_bytes {
cfg.write_batch_bytes = value.max(1);
}
if let Some(value) = file.tcp_nodelay {
cfg.tcp_nodelay = value;
}
if let Some(value) = file.backlog {
cfg.backlog = Some(value.max(1));
}
}
fn parse_tenant_policy(policy: &str) -> Result<TenantNoMatchPolicy> {
match policy.to_ascii_lowercase().as_str() {
"reject" => Ok(TenantNoMatchPolicy::Reject),
"username" => Ok(TenantNoMatchPolicy::Username),
"literal" => Ok(TenantNoMatchPolicy::Literal),
_ => anyhow::bail!("invalid tenant no-match policy: {}", policy),
}
}
fn tenant_policy_name(policy: TenantNoMatchPolicy) -> &'static str {
match policy {
TenantNoMatchPolicy::Reject => "reject",
TenantNoMatchPolicy::Username => "username",
TenantNoMatchPolicy::Literal => "literal",
}
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(raw)
}
})
}
fn env_string_opt(key: &str) -> Result<Option<String>> {
Ok(env::var(key).ok().filter(|v| !v.is_empty()))
}
fn env_u64_opt(key: &str) -> Result<Option<u64>> {
env::var(key)
.ok()
.filter(|v| !v.is_empty())
.map(|value| {
value
.parse::<u64>()
.with_context(|| format!("invalid {}", key))
})
.transpose()
}
fn env_u32_opt(key: &str) -> Result<Option<u32>> {
env::var(key)
.ok()
.filter(|v| !v.is_empty())
.map(|value| {
value
.parse::<u32>()
.with_context(|| format!("invalid {}", key))
})
.transpose()
}
fn env_usize(key: &str) -> Result<Option<usize>> {
env::var(key)
.ok()
.filter(|v| !v.is_empty())
.map(|value| {
value
.parse::<usize>()
.with_context(|| format!("invalid {}", key))
})
.transpose()
}
fn env_bool(key: &str) -> Result<Option<bool>> {
let Some(raw) = env::var(key).ok().filter(|v| !v.is_empty()) else {
return Ok(None);
};
let parsed = match raw.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" => true,
"0" | "false" | "no" => false,
_ => anyhow::bail!("invalid {} (expected true/false)", key),
};
Ok(Some(parsed))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tenant_id_named_group() {
let cfg = AuthConfig::new("^(?P<tenant>[^:]+):", "username").unwrap();
let tenant = cfg.tenant_id("tenantA:alice").unwrap();
assert_eq!(tenant.as_ref(), b"tenantA");
let tenant = cfg.tenant_id("tenantSolo").unwrap();
assert_eq!(tenant.as_ref(), b"tenantSolo");
}
#[test]
fn tenant_id_literal_policy() {
let cfg = AuthConfig::new("^([^:]+):", "literal").unwrap();
let tenant = cfg.tenant_id("tenantB:carol").unwrap();
assert_eq!(tenant.as_ref(), b"tenantB");
let tenant = cfg.tenant_id("tenantSolo").unwrap();
assert_eq!(tenant.as_ref(), b"^([^:]+):");
}
#[test]
fn tenant_id_reject_policy() {
let cfg = AuthConfig::new("^([^:]+):", "reject").unwrap();
let err = cfg.tenant_id("tenantSolo");
assert!(matches!(err, Err(TenantError::NoMatch)));
}
}