generalbots/botserver/src/security/api_keys.rs
Rodrigo Rodriguez (Pragmatismo) 037db5c381 feat: Major workspace reorganization and documentation update
- Add comprehensive documentation in botbook/ with 12 chapters
- Add botapp/ Tauri desktop application
- Add botdevice/ IoT device support
- Add botlib/ shared library crate
- Add botmodels/ Python ML models service
- Add botplugin/ browser extension
- Add botserver/ reorganized server code
- Add bottemplates/ bot templates
- Add bottest/ integration tests
- Add botui/ web UI server
- Add CI/CD workflows in .forgejo/workflows/
- Add AGENTS.md and PROD.md documentation
- Add dependency management scripts (DEPENDENCIES.sh/ps1)
- Remove legacy src/ structure and migrations
- Clean up temporary and backup files
2026-04-19 08:14:25 -03:00

1006 lines
29 KiB
Rust

use anyhow::{anyhow, Result};
use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::info;
use uuid::Uuid;
const API_KEY_PREFIX: &str = "gb_";
const API_KEY_LENGTH: usize = 32;
const API_KEY_HASH_ITERATIONS: u32 = 100_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeyConfig {
pub default_rate_limit_per_minute: u32,
pub default_rate_limit_per_hour: u32,
pub default_rate_limit_per_day: u32,
pub max_keys_per_user: usize,
pub key_expiry_days: Option<u32>,
pub allow_key_rotation: bool,
pub rotation_grace_period_hours: u32,
}
impl Default for ApiKeyConfig {
fn default() -> Self {
Self {
default_rate_limit_per_minute: 60,
default_rate_limit_per_hour: 1000,
default_rate_limit_per_day: 10000,
max_keys_per_user: 10,
key_expiry_days: Some(365),
allow_key_rotation: true,
rotation_grace_period_hours: 24,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ApiKeyScope {
Read,
Write,
Delete,
Admin,
Bot(Uuid),
Resource(String),
Custom(String),
}
impl ApiKeyScope {
pub fn as_str(&self) -> String {
match self {
Self::Read => "read".into(),
Self::Write => "write".into(),
Self::Delete => "delete".into(),
Self::Admin => "admin".into(),
Self::Bot(id) => format!("bot:{id}"),
Self::Resource(r) => format!("resource:{r}"),
Self::Custom(c) => format!("custom:{c}"),
}
}
}
impl std::str::FromStr for ApiKeyScope {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"read" => Ok(Self::Read),
"write" => Ok(Self::Write),
"delete" => Ok(Self::Delete),
"admin" => Ok(Self::Admin),
s if s.starts_with("bot:") => {
let id = s.strip_prefix("bot:").ok_or(())?;
Uuid::parse_str(id).map_err(|_| ()).map(Self::Bot)
}
s if s.starts_with("resource:") => {
let r = s.strip_prefix("resource:").ok_or(())?;
Ok(Self::Resource(r.to_string()))
}
s if s.starts_with("custom:") => {
let c = s.strip_prefix("custom:").ok_or(())?;
Ok(Self::Custom(c.to_string()))
}
_ => Err(()),
}
}
}
impl ApiKeyScope {
pub fn includes(&self, other: &Self) -> bool {
if self == &Self::Admin {
return true;
}
if self == other {
return true;
}
if self == &Self::Write && other == &Self::Read {
return true;
}
false
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ApiKeyStatus {
Active,
Expired,
Revoked,
Rotating,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
pub requests_per_minute: u32,
pub requests_per_hour: u32,
pub requests_per_day: u32,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
requests_per_minute: 60,
requests_per_hour: 1000,
requests_per_day: 10000,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKey {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub description: Option<String>,
pub key_hash: String,
pub key_prefix: String,
pub scopes: HashSet<ApiKeyScope>,
pub status: ApiKeyStatus,
pub rate_limits: RateLimitConfig,
pub allowed_ips: Option<Vec<String>>,
pub allowed_origins: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub rotated_from: Option<Uuid>,
pub rotation_deadline: Option<DateTime<Utc>>,
pub metadata: HashMap<String, String>,
}
impl ApiKey {
pub fn is_valid(&self) -> bool {
if self.status != ApiKeyStatus::Active && self.status != ApiKeyStatus::Rotating {
return false;
}
if let Some(expires) = self.expires_at {
if Utc::now() > expires {
return false;
}
}
if self.status == ApiKeyStatus::Rotating {
if let Some(deadline) = self.rotation_deadline {
if Utc::now() > deadline {
return false;
}
}
}
true
}
pub fn has_scope(&self, scope: &ApiKeyScope) -> bool {
if self.scopes.contains(&ApiKeyScope::Admin) {
return true;
}
for s in &self.scopes {
if s.includes(scope) {
return true;
}
}
false
}
pub fn has_any_scope(&self, scopes: &[ApiKeyScope]) -> bool {
scopes.iter().any(|s| self.has_scope(s))
}
pub fn has_all_scopes(&self, scopes: &[ApiKeyScope]) -> bool {
scopes.iter().all(|s| self.has_scope(s))
}
pub fn is_ip_allowed(&self, ip: &str) -> bool {
match &self.allowed_ips {
Some(ips) if !ips.is_empty() => ips.iter().any(|allowed| {
if allowed.contains('/') {
matches_cidr(ip, allowed)
} else {
allowed == ip
}
}),
_ => true,
}
}
pub fn is_origin_allowed(&self, origin: &str) -> bool {
match &self.allowed_origins {
Some(origins) if !origins.is_empty() => origins.iter().any(|allowed| {
if allowed == "*" {
true
} else if allowed.starts_with("*.") {
let suffix = allowed.strip_prefix("*").unwrap_or(allowed);
origin.ends_with(suffix)
} else {
allowed == origin
}
}),
_ => true,
}
}
pub fn time_until_expiry(&self) -> Option<Duration> {
self.expires_at.map(|e| e - Utc::now())
}
pub fn is_expiring_soon(&self, days: i64) -> bool {
if let Some(remaining) = self.time_until_expiry() {
remaining < Duration::days(days)
} else {
false
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateApiKeyRequest {
pub name: String,
pub description: Option<String>,
pub scopes: Vec<ApiKeyScope>,
pub expires_in_days: Option<u32>,
pub rate_limits: Option<RateLimitConfig>,
pub allowed_ips: Option<Vec<String>>,
pub allowed_origins: Option<Vec<String>>,
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateApiKeyResponse {
pub key: ApiKey,
pub secret: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeyUsage {
pub key_id: Uuid,
pub requests_this_minute: u32,
pub requests_this_hour: u32,
pub requests_this_day: u32,
pub minute_reset_at: DateTime<Utc>,
pub hour_reset_at: DateTime<Utc>,
pub day_reset_at: DateTime<Utc>,
pub total_requests: u64,
pub last_request_at: Option<DateTime<Utc>>,
}
impl ApiKeyUsage {
pub fn new(key_id: Uuid) -> Self {
let now = Utc::now();
Self {
key_id,
requests_this_minute: 0,
requests_this_hour: 0,
requests_this_day: 0,
minute_reset_at: now + Duration::minutes(1),
hour_reset_at: now + Duration::hours(1),
day_reset_at: now + Duration::days(1),
total_requests: 0,
last_request_at: None,
}
}
pub fn record_request(&mut self) {
let now = Utc::now();
if now >= self.minute_reset_at {
self.requests_this_minute = 0;
self.minute_reset_at = now + Duration::minutes(1);
}
if now >= self.hour_reset_at {
self.requests_this_hour = 0;
self.hour_reset_at = now + Duration::hours(1);
}
if now >= self.day_reset_at {
self.requests_this_day = 0;
self.day_reset_at = now + Duration::days(1);
}
self.requests_this_minute += 1;
self.requests_this_hour += 1;
self.requests_this_day += 1;
self.total_requests += 1;
self.last_request_at = Some(now);
}
pub fn is_rate_limited(&self, limits: &RateLimitConfig) -> bool {
let now = Utc::now();
if now < self.minute_reset_at && self.requests_this_minute >= limits.requests_per_minute {
return true;
}
if now < self.hour_reset_at && self.requests_this_hour >= limits.requests_per_hour {
return true;
}
if now < self.day_reset_at && self.requests_this_day >= limits.requests_per_day {
return true;
}
false
}
pub fn time_until_reset(&self, limits: &RateLimitConfig) -> Option<Duration> {
let now = Utc::now();
if now < self.minute_reset_at && self.requests_this_minute >= limits.requests_per_minute {
return Some(self.minute_reset_at - now);
}
if now < self.hour_reset_at && self.requests_this_hour >= limits.requests_per_hour {
return Some(self.hour_reset_at - now);
}
if now < self.day_reset_at && self.requests_this_day >= limits.requests_per_day {
return Some(self.day_reset_at - now);
}
None
}
}
pub struct ApiKeyManager {
config: ApiKeyConfig,
keys: Arc<RwLock<HashMap<Uuid, ApiKey>>>,
key_hashes: Arc<RwLock<HashMap<String, Uuid>>>,
usage: Arc<RwLock<HashMap<Uuid, ApiKeyUsage>>>,
}
impl ApiKeyManager {
pub fn new(config: ApiKeyConfig) -> Self {
Self {
config,
keys: Arc::new(RwLock::new(HashMap::new())),
key_hashes: Arc::new(RwLock::new(HashMap::new())),
usage: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn with_defaults() -> Self {
Self::new(ApiKeyConfig::default())
}
pub async fn create_key(
&self,
user_id: Uuid,
request: CreateApiKeyRequest,
) -> Result<CreateApiKeyResponse> {
let user_keys = self.get_user_keys(user_id).await;
if user_keys.len() >= self.config.max_keys_per_user {
return Err(anyhow!(
"Maximum number of API keys ({}) reached",
self.config.max_keys_per_user
));
}
let (secret, key_hash) = generate_api_key();
let key_prefix = extract_prefix(&secret);
let expires_at = request
.expires_in_days
.or(self.config.key_expiry_days)
.map(|days| Utc::now() + Duration::days(days as i64));
let rate_limits = request.rate_limits.unwrap_or(RateLimitConfig {
requests_per_minute: self.config.default_rate_limit_per_minute,
requests_per_hour: self.config.default_rate_limit_per_hour,
requests_per_day: self.config.default_rate_limit_per_day,
});
let key = ApiKey {
id: Uuid::new_v4(),
user_id,
name: request.name,
description: request.description,
key_hash: key_hash.clone(),
key_prefix,
scopes: request.scopes.into_iter().collect(),
status: ApiKeyStatus::Active,
rate_limits,
allowed_ips: request.allowed_ips,
allowed_origins: request.allowed_origins,
created_at: Utc::now(),
expires_at,
last_used_at: None,
rotated_from: None,
rotation_deadline: None,
metadata: request.metadata.unwrap_or_default(),
};
let key_id = key.id;
{
let mut keys = self.keys.write().await;
keys.insert(key_id, key.clone());
}
{
let mut hashes = self.key_hashes.write().await;
hashes.insert(key_hash, key_id);
}
info!("Created API key {} for user {}", key_id, user_id);
Ok(CreateApiKeyResponse { key, secret })
}
pub async fn validate_key(&self, secret: &str) -> Result<Option<ApiKey>> {
let key_hash = hash_api_key(secret);
let key_id = {
let hashes = self.key_hashes.read().await;
hashes.get(&key_hash).copied()
};
let key_id = match key_id {
Some(id) => id,
None => return Ok(None),
};
let key = {
let keys = self.keys.read().await;
keys.get(&key_id).cloned()
};
let key = match key {
Some(k) => k,
None => return Ok(None),
};
if !key.is_valid() {
return Ok(None);
}
{
let mut keys = self.keys.write().await;
if let Some(k) = keys.get_mut(&key_id) {
k.last_used_at = Some(Utc::now());
}
}
Ok(Some(key))
}
pub async fn validate_and_check_rate_limit(&self, secret: &str) -> Result<(ApiKey, bool)> {
let key = self
.validate_key(secret)
.await?
.ok_or_else(|| anyhow!("Invalid API key"))?;
let is_rate_limited = {
let mut usage = self.usage.write().await;
let key_usage = usage
.entry(key.id)
.or_insert_with(|| ApiKeyUsage::new(key.id));
if key_usage.is_rate_limited(&key.rate_limits) {
true
} else {
key_usage.record_request();
false
}
};
Ok((key, is_rate_limited))
}
pub async fn get_key(&self, key_id: Uuid) -> Option<ApiKey> {
let keys = self.keys.read().await;
keys.get(&key_id).cloned()
}
pub async fn get_user_keys(&self, user_id: Uuid) -> Vec<ApiKey> {
let keys = self.keys.read().await;
keys.values()
.filter(|k| k.user_id == user_id)
.cloned()
.collect()
}
pub async fn revoke_key(&self, key_id: Uuid) -> Result<bool> {
let mut keys = self.keys.write().await;
if let Some(key) = keys.get_mut(&key_id) {
key.status = ApiKeyStatus::Revoked;
info!("Revoked API key {}", key_id);
Ok(true)
} else {
Ok(false)
}
}
pub async fn revoke_all_user_keys(&self, user_id: Uuid) -> Result<usize> {
let mut keys = self.keys.write().await;
let mut revoked = 0;
for key in keys.values_mut() {
if key.user_id == user_id && key.status == ApiKeyStatus::Active {
key.status = ApiKeyStatus::Revoked;
revoked += 1;
}
}
info!("Revoked {} API keys for user {}", revoked, user_id);
Ok(revoked)
}
pub async fn rotate_key(&self, key_id: Uuid) -> Result<CreateApiKeyResponse> {
if !self.config.allow_key_rotation {
return Err(anyhow!("API key rotation is not enabled"));
}
let old_key = {
let keys = self.keys.read().await;
keys.get(&key_id).cloned()
};
let old_key = old_key.ok_or_else(|| anyhow!("API key not found"))?;
if old_key.status != ApiKeyStatus::Active {
return Err(anyhow!("Can only rotate active keys"));
}
let (secret, key_hash) = generate_api_key();
let key_prefix = extract_prefix(&secret);
let grace_period = Duration::hours(self.config.rotation_grace_period_hours as i64);
let new_key = ApiKey {
id: Uuid::new_v4(),
user_id: old_key.user_id,
name: old_key.name.clone(),
description: old_key.description.clone(),
key_hash: key_hash.clone(),
key_prefix,
scopes: old_key.scopes.clone(),
status: ApiKeyStatus::Active,
rate_limits: old_key.rate_limits.clone(),
allowed_ips: old_key.allowed_ips.clone(),
allowed_origins: old_key.allowed_origins.clone(),
created_at: Utc::now(),
expires_at: old_key.expires_at,
last_used_at: None,
rotated_from: Some(key_id),
rotation_deadline: None,
metadata: old_key.metadata.clone(),
};
let new_key_id = new_key.id;
{
let mut keys = self.keys.write().await;
if let Some(old) = keys.get_mut(&key_id) {
old.status = ApiKeyStatus::Rotating;
old.rotation_deadline = Some(Utc::now() + grace_period);
}
keys.insert(new_key_id, new_key.clone());
}
{
let mut hashes = self.key_hashes.write().await;
hashes.insert(key_hash, new_key_id);
}
info!(
"Rotated API key {} to {} (grace period: {} hours)",
key_id, new_key_id, self.config.rotation_grace_period_hours
);
Ok(CreateApiKeyResponse {
key: new_key,
secret,
})
}
pub async fn update_key_scopes(&self, key_id: Uuid, scopes: Vec<ApiKeyScope>) -> Result<bool> {
let mut keys = self.keys.write().await;
if let Some(key) = keys.get_mut(&key_id) {
key.scopes = scopes.into_iter().collect();
info!("Updated scopes for API key {}", key_id);
Ok(true)
} else {
Ok(false)
}
}
pub async fn update_key_rate_limits(
&self,
key_id: Uuid,
rate_limits: RateLimitConfig,
) -> Result<bool> {
let mut keys = self.keys.write().await;
if let Some(key) = keys.get_mut(&key_id) {
key.rate_limits = rate_limits;
info!("Updated rate limits for API key {}", key_id);
Ok(true)
} else {
Ok(false)
}
}
pub async fn get_key_usage(&self, key_id: Uuid) -> Option<ApiKeyUsage> {
let usage = self.usage.read().await;
usage.get(&key_id).cloned()
}
pub async fn cleanup_expired_keys(&self) -> Result<usize> {
let mut keys = self.keys.write().await;
let mut hashes = self.key_hashes.write().await;
let expired_ids: Vec<Uuid> = keys
.iter()
.filter(|(_, k)| {
if let Some(expires) = k.expires_at {
Utc::now() > expires
} else {
false
}
})
.map(|(id, _)| *id)
.collect();
for id in &expired_ids {
if let Some(key) = keys.remove(id) {
hashes.remove(&key.key_hash);
}
}
if !expired_ids.is_empty() {
info!("Cleaned up {} expired API keys", expired_ids.len());
}
Ok(expired_ids.len())
}
pub async fn cleanup_rotation_grace_periods(&self) -> Result<usize> {
let mut keys = self.keys.write().await;
let mut count = 0;
for key in keys.values_mut() {
if key.status == ApiKeyStatus::Rotating {
if let Some(deadline) = key.rotation_deadline {
if Utc::now() > deadline {
key.status = ApiKeyStatus::Revoked;
count += 1;
}
}
}
}
if count > 0 {
info!(
"Expired {} API keys past rotation grace period",
count
);
}
Ok(count)
}
pub async fn get_expiring_keys(&self, days: i64) -> Vec<ApiKey> {
let keys = self.keys.read().await;
keys.values()
.filter(|k| k.status == ApiKeyStatus::Active && k.is_expiring_soon(days))
.cloned()
.collect()
}
pub fn config(&self) -> &ApiKeyConfig {
&self.config
}
}
fn generate_api_key() -> (String, String) {
let mut rng = rand::rng();
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let random_part: String = (0..API_KEY_LENGTH)
.map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
.collect();
let secret = format!("{API_KEY_PREFIX}{random_part}");
let hash = hash_api_key(&secret);
(secret, hash)
}
fn hash_api_key(key: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(key.as_bytes());
for _ in 0..API_KEY_HASH_ITERATIONS {
let result = hasher.finalize_reset();
hasher.update(result);
}
let result = hasher.finalize();
hex::encode(result)
}
fn extract_prefix(key: &str) -> String {
let stripped = key.strip_prefix(API_KEY_PREFIX).unwrap_or(key);
format!("{API_KEY_PREFIX}{}...", &stripped[..8.min(stripped.len())])
}
fn matches_cidr(ip: &str, cidr: &str) -> bool {
let parts: Vec<&str> = cidr.split('/').collect();
if parts.len() != 2 {
return false;
}
let network = parts[0];
let prefix_len: u8 = match parts[1].parse() {
Ok(p) => p,
Err(_) => return false,
};
let ip_octets: Vec<u8> = match ip
.split('.')
.map(|s| s.parse())
.collect::<Result<Vec<u8>, _>>()
{
Ok(o) if o.len() == 4 => o,
_ => return false,
};
let network_octets: Vec<u8> = match network
.split('.')
.map(|s| s.parse())
.collect::<Result<Vec<u8>, _>>()
{
Ok(o) if o.len() == 4 => o,
_ => return false,
};
let ip_u32 = u32::from_be_bytes([ip_octets[0], ip_octets[1], ip_octets[2], ip_octets[3]]);
let network_u32 = u32::from_be_bytes([
network_octets[0],
network_octets[1],
network_octets[2],
network_octets[3],
]);
let mask = if prefix_len >= 32 {
u32::MAX
} else {
u32::MAX << (32 - prefix_len)
};
(ip_u32 & mask) == (network_u32 & mask)
}
pub fn extract_api_key_from_header(header_value: &str) -> Option<&str> {
if let Some(key) = header_value.strip_prefix("Bearer ") {
return Some(key);
}
if let Some(key) = header_value.strip_prefix("bearer ") {
return Some(key);
}
if let Some(key) = header_value.strip_prefix("ApiKey ") {
return Some(key);
}
if let Some(key) = header_value.strip_prefix("api-key ") {
return Some(key);
}
if header_value.starts_with(API_KEY_PREFIX) {
return Some(header_value);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_create_and_validate_key() {
let manager = ApiKeyManager::with_defaults();
let user_id = Uuid::new_v4();
let request = CreateApiKeyRequest {
name: "Test Key".into(),
description: Some("A test API key".into()),
scopes: vec![ApiKeyScope::Read, ApiKeyScope::Write],
expires_in_days: Some(30),
rate_limits: None,
allowed_ips: None,
allowed_origins: None,
metadata: None,
};
let response = manager.create_key(user_id, request).await.expect("Create failed");
assert!(response.secret.starts_with(API_KEY_PREFIX));
assert_eq!(response.key.user_id, user_id);
assert!(response.key.is_valid());
let validated = manager
.validate_key(&response.secret)
.await
.expect("Validate failed");
assert!(validated.is_some());
assert_eq!(validated.as_ref().map(|k| k.id), Some(response.key.id));
}
#[tokio::test]
async fn test_key_scopes() {
let manager = ApiKeyManager::with_defaults();
let user_id = Uuid::new_v4();
let request = CreateApiKeyRequest {
name: "Limited Key".into(),
description: None,
scopes: vec![ApiKeyScope::Read],
expires_in_days: None,
rate_limits: None,
allowed_ips: None,
allowed_origins: None,
metadata: None,
};
let response = manager.create_key(user_id, request).await.expect("Create failed");
assert!(response.key.has_scope(&ApiKeyScope::Read));
assert!(!response.key.has_scope(&ApiKeyScope::Write));
assert!(!response.key.has_scope(&ApiKeyScope::Admin));
}
#[tokio::test]
async fn test_admin_scope_includes_all() {
let manager = ApiKeyManager::with_defaults();
let user_id = Uuid::new_v4();
let request = CreateApiKeyRequest {
name: "Admin Key".into(),
description: None,
scopes: vec![ApiKeyScope::Admin],
expires_in_days: None,
rate_limits: None,
allowed_ips: None,
allowed_origins: None,
metadata: None,
};
let response = manager.create_key(user_id, request).await.expect("Create failed");
assert!(response.key.has_scope(&ApiKeyScope::Read));
assert!(response.key.has_scope(&ApiKeyScope::Write));
assert!(response.key.has_scope(&ApiKeyScope::Delete));
assert!(response.key.has_scope(&ApiKeyScope::Admin));
}
#[tokio::test]
async fn test_revoke_key() {
let manager = ApiKeyManager::with_defaults();
let user_id = Uuid::new_v4();
let request = CreateApiKeyRequest {
name: "Revokable Key".into(),
description: None,
scopes: vec![ApiKeyScope::Read],
expires_in_days: None,
rate_limits: None,
allowed_ips: None,
allowed_origins: None,
metadata: None,
};
let response = manager.create_key(user_id, request).await.expect("Create failed");
let key_id = response.key.id;
manager.revoke_key(key_id).await.expect("Revoke failed");
let validated = manager
.validate_key(&response.secret)
.await
.expect("Validate failed");
assert!(validated.is_none());
}
#[tokio::test]
async fn test_rate_limiting() {
let manager = ApiKeyManager::with_defaults();
let user_id = Uuid::new_v4();
let request = CreateApiKeyRequest {
name: "Rate Limited Key".into(),
description: None,
scopes: vec![ApiKeyScope::Read],
expires_in_days: None,
rate_limits: Some(RateLimitConfig {
requests_per_minute: 2,
requests_per_hour: 100,
requests_per_day: 1000,
}),
allowed_ips: None,
allowed_origins: None,
metadata: None,
};
let response = manager.create_key(user_id, request).await.expect("Create failed");
let (_, limited1) = manager
.validate_and_check_rate_limit(&response.secret)
.await
.expect("Validate failed");
assert!(!limited1);
let (_, limited2) = manager
.validate_and_check_rate_limit(&response.secret)
.await
.expect("Validate failed");
assert!(!limited2);
let (_, limited3) = manager
.validate_and_check_rate_limit(&response.secret)
.await
.expect("Validate failed");
assert!(limited3);
}
#[test]
fn test_ip_cidr_matching() {
assert!(matches_cidr("192.168.1.100", "192.168.1.0/24"));
assert!(matches_cidr("192.168.1.1", "192.168.1.0/24"));
assert!(!matches_cidr("192.168.2.1", "192.168.1.0/24"));
assert!(matches_cidr("10.0.0.1", "10.0.0.0/8"));
}
#[test]
fn test_extract_api_key_from_header() {
assert_eq!(
extract_api_key_from_header("Bearer gb_abc123"),
Some("gb_abc123")
);
assert_eq!(
extract_api_key_from_header("ApiKey gb_xyz789"),
Some("gb_xyz789")
);
assert_eq!(
extract_api_key_from_header("gb_direct_key"),
Some("gb_direct_key")
);
assert_eq!(extract_api_key_from_header("Basic abc123"), None);
}
#[test]
fn test_scope_inclusion() {
assert!(ApiKeyScope::Admin.includes(&ApiKeyScope::Read));
assert!(ApiKeyScope::Admin.includes(&ApiKeyScope::Write));
assert!(ApiKeyScope::Write.includes(&ApiKeyScope::Read));
assert!(!ApiKeyScope::Read.includes(&ApiKeyScope::Write));
}
#[test]
fn test_origin_matching() {
let key = ApiKey {
id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
name: "Test".into(),
description: None,
key_hash: "hash".into(),
key_prefix: "gb_abc...".into(),
scopes: HashSet::new(),
status: ApiKeyStatus::Active,
rate_limits: RateLimitConfig::default(),
allowed_ips: None,
allowed_origins: Some(vec!["*.example.com".into(), "https://specific.org".into()]),
created_at: Utc::now(),
expires_at: None,
last_used_at: None,
rotated_from: None,
rotation_deadline: None,
metadata: HashMap::new(),
};
assert!(key.is_origin_allowed("api.example.com"));
assert!(key.is_origin_allowed("sub.example.com"));
assert!(key.is_origin_allowed("https://specific.org"));
assert!(!key.is_origin_allowed("https://other.org"));
}
}