use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use axum::{
Json,
extract::{Request, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use chrono::{DateTime, Utc};
use rand::{RngCore, TryRngCore};
use agentstategraph::Repository;
#[derive(Default)]
pub struct CreateKeyOptions {
pub commit_agent_id: Option<String>,
pub can_migrate: bool,
pub is_admin: bool,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ApiKey {
pub key: String,
pub tenant_id: String,
pub name: String,
pub plan: String, pub enabled: bool,
pub created_at: String,
#[serde(default)]
pub commit_agent_id: Option<String>,
#[serde(default)]
pub namespace_id: Option<String>,
#[serde(default)]
pub can_migrate: bool,
#[serde(default)]
pub is_admin: bool,
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default)]
pub struct AuthContext {
pub commit_agent_id: Option<String>,
pub can_migrate: bool,
pub key_prefix: Option<String>,
pub namespace_id: Option<String>,
}
pub struct TenantManager {
keys: RwLock<HashMap<String, ApiKey>>,
_repos: RwLock<HashMap<String, Arc<Repository>>>,
default_repo: Arc<Repository>,
auth_enabled: bool,
keys_file: Option<String>,
}
impl TenantManager {
pub fn single_tenant(repo: Arc<Repository>) -> Arc<Self> {
Arc::new(Self {
keys: RwLock::new(HashMap::new()),
_repos: RwLock::new(HashMap::new()),
default_repo: repo,
auth_enabled: false,
keys_file: None,
})
}
pub fn multi_tenant(default_repo: Arc<Repository>, keys_file: Option<&str>) -> Arc<Self> {
let mut keys = HashMap::new();
if let Some(path) = keys_file
&& let Ok(data) = std::fs::read_to_string(path)
&& let Ok(loaded) = serde_json::from_str::<Vec<ApiKey>>(&data)
{
for key in loaded {
keys.insert(key.key.clone(), key);
}
}
Arc::new(Self {
keys: RwLock::new(keys),
_repos: RwLock::new(HashMap::new()),
default_repo,
auth_enabled: true,
keys_file: keys_file.map(|s| s.to_string()),
})
}
pub fn register_key(&self, api_key: ApiKey) {
if let Ok(mut keys) = self.keys.write() {
keys.insert(api_key.key.clone(), api_key);
self.save_keys();
}
}
#[allow(dead_code)]
pub fn create_key(&self, tenant_id: &str, name: &str, plan: &str) -> ApiKey {
self.create_key_with(tenant_id, name, plan, CreateKeyOptions::default())
}
pub fn create_key_with(
&self,
tenant_id: &str,
name: &str,
plan: &str,
opts: CreateKeyOptions,
) -> ApiKey {
let key = generate_key();
let api_key = ApiKey {
key: key.clone(),
tenant_id: tenant_id.to_string(),
name: name.to_string(),
plan: plan.to_string(),
enabled: true,
created_at: Utc::now().to_rfc3339(),
namespace_id: None,
commit_agent_id: opts.commit_agent_id,
can_migrate: opts.can_migrate,
is_admin: opts.is_admin,
expires_at: opts.expires_at,
last_used_at: None,
};
self.register_key(api_key.clone());
api_key
}
pub fn rotate_key(&self, key_prefix: &str) -> Option<ApiKey> {
let (
tenant_id,
name,
plan,
namespace_id,
commit_agent_id,
can_migrate,
is_admin,
expires_at,
) = {
let keys = self.keys.read().ok()?;
let full = keys.keys().find(|k| k.starts_with(key_prefix)).cloned()?;
let old = keys.get(&full)?;
(
old.tenant_id.clone(),
old.name.clone(),
old.plan.clone(),
old.namespace_id.clone(),
old.commit_agent_id.clone(),
old.can_migrate,
old.is_admin,
old.expires_at,
)
};
let mut new_key = self.create_key_with(
&tenant_id,
&name,
&plan,
CreateKeyOptions {
commit_agent_id,
can_migrate,
is_admin,
expires_at,
},
);
new_key.namespace_id = namespace_id;
self.revoke_key(key_prefix);
Some(new_key)
}
pub fn register_admin_key(&self, key: String, name: &str) -> bool {
let mut keys = match self.keys.write() {
Ok(k) => k,
Err(e) => e.into_inner(),
};
if keys.contains_key(&key) {
return false;
}
keys.insert(
key.clone(),
ApiKey {
key,
tenant_id: "admin".to_string(),
name: name.to_string(),
plan: "admin".to_string(),
enabled: true,
created_at: Utc::now().to_rfc3339(),
namespace_id: None,
commit_agent_id: None,
can_migrate: true,
is_admin: true,
expires_at: None,
last_used_at: None,
},
);
drop(keys);
self.save_keys();
true
}
pub fn has_admin_key(&self) -> bool {
let Ok(keys) = self.keys.read() else {
return false;
};
keys.values().any(|k| k.enabled && k.is_admin)
}
pub fn is_admin(&self, api_key: Option<&str>) -> bool {
if !self.auth_enabled {
return true;
}
let Some(key) = api_key else {
return false;
};
let Ok(keys) = self.keys.read() else {
return false;
};
keys.get(key)
.map(|k| is_key_live(k) && k.is_admin)
.unwrap_or(false)
}
pub fn get_agent_id(&self, api_key: &str) -> Option<String> {
let keys = self.keys.read().ok()?;
let k = keys.get(api_key)?;
if !is_key_live(k) {
return None;
}
k.commit_agent_id.clone()
}
pub fn can_migrate(&self, api_key: &str) -> bool {
if !self.auth_enabled {
return true;
}
let Ok(keys) = self.keys.read() else {
return false;
};
keys.get(api_key)
.map(|k| is_key_live(k) && k.can_migrate)
.unwrap_or(false)
}
pub fn get_namespace_id(&self, api_key: &str) -> Option<String> {
let Ok(keys) = self.keys.read() else {
return None;
};
keys.get(api_key)
.filter(|k| is_key_live(k))
.and_then(|k| k.namespace_id.clone())
}
pub fn auth_enabled(&self) -> bool {
self.auth_enabled
}
pub fn list_keys(&self) -> Vec<serde_json::Value> {
let keys = self.keys.read().unwrap_or_else(|e| e.into_inner());
keys.values()
.map(|k| {
let masked = if k.key.len() > 12 {
format!("{}...{}", &k.key[..8], &k.key[k.key.len() - 4..])
} else {
"***".to_string()
};
serde_json::json!({
"key_preview": masked,
"tenant_id": k.tenant_id,
"name": k.name,
"plan": k.plan,
"enabled": k.enabled,
"created_at": k.created_at,
"commit_agent_id": k.commit_agent_id,
"can_migrate": k.can_migrate,
"expires_at": k.expires_at.map(|t| t.to_rfc3339()),
"last_used_at": k.last_used_at.map(|t| t.to_rfc3339()),
})
})
.collect()
}
pub fn revoke_key(&self, key_prefix: &str) -> bool {
if let Ok(mut keys) = self.keys.write() {
let matching: Vec<String> = keys
.keys()
.filter(|k| k.starts_with(key_prefix))
.cloned()
.collect();
if let Some(key) = matching.first()
&& let Some(api_key) = keys.get_mut(key)
{
api_key.enabled = false;
self.save_keys();
return true;
}
}
false
}
pub fn resolve_tenant(&self, api_key: &str) -> Option<String> {
let (tenant, live) = {
let keys = self.keys.read().ok()?;
let key_info = keys.get(api_key)?;
(key_info.tenant_id.clone(), is_key_live(key_info))
};
if !live {
return None;
}
if let Ok(mut keys) = self.keys.write()
&& let Some(k) = keys.get_mut(api_key)
{
k.last_used_at = Some(Utc::now());
}
Some(tenant)
}
pub fn get_repo(&self, api_key: Option<&str>) -> Result<Arc<Repository>, AuthError> {
if !self.auth_enabled {
return Ok(self.default_repo.clone());
}
let key = api_key.ok_or(AuthError::MissingKey)?;
let _tenant_id = self.resolve_tenant(key).ok_or(AuthError::InvalidKey)?;
Ok(self.default_repo.clone())
}
fn save_keys(&self) {
if let Some(ref path) = self.keys_file
&& let Ok(keys) = self.keys.read()
{
let all: Vec<&ApiKey> = keys.values().collect();
if let Ok(data) = serde_json::to_string_pretty(&all) {
let _ = std::fs::write(path, data);
}
}
}
}
#[derive(Debug)]
pub enum AuthError {
MissingKey,
InvalidKey,
NotAdmin,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, msg) = match self {
AuthError::MissingKey => (
StatusCode::UNAUTHORIZED,
"Missing API key. Pass it as: Authorization: Bearer asg_...",
),
AuthError::InvalidKey => (StatusCode::UNAUTHORIZED, "Invalid or revoked API key."),
AuthError::NotAdmin => (
StatusCode::FORBIDDEN,
"Admin privilege required for this endpoint.",
),
};
(status, Json(serde_json::json!({ "error": msg }))).into_response()
}
}
pub async fn auth_middleware(
State(tenant_mgr): State<Arc<TenantManager>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
let api_key = extract_api_key(&headers);
let repo = tenant_mgr.get_repo(api_key.as_deref())?;
let ctx = if tenant_mgr.auth_enabled() {
let commit_agent_id = api_key.as_deref().and_then(|k| tenant_mgr.get_agent_id(k));
let can_migrate = api_key
.as_deref()
.map(|k| tenant_mgr.can_migrate(k))
.unwrap_or(false);
let namespace_id = api_key
.as_deref()
.and_then(|k| tenant_mgr.get_namespace_id(k));
let key_prefix = api_key.as_deref().map(|k| {
if k.len() > 8 {
k[..8].to_string()
} else {
k.to_string()
}
});
AuthContext {
commit_agent_id,
can_migrate,
key_prefix,
namespace_id,
}
} else {
AuthContext {
commit_agent_id: None,
can_migrate: true,
key_prefix: None,
namespace_id: None,
}
};
request.extensions_mut().insert(repo);
request.extensions_mut().insert(ctx);
Ok(next.run(request).await)
}
pub async fn admin_auth_middleware(
State(tenant_mgr): State<Arc<TenantManager>>,
headers: HeaderMap,
request: Request,
next: Next,
) -> Result<Response, AuthError> {
if !tenant_mgr.auth_enabled() {
return Ok(next.run(request).await);
}
let api_key = extract_api_key(&headers);
let Some(key) = api_key.as_deref() else {
return Err(AuthError::MissingKey);
};
if tenant_mgr.resolve_tenant(key).is_none() {
return Err(AuthError::InvalidKey);
}
if !tenant_mgr.is_admin(Some(key)) {
return Err(AuthError::NotAdmin);
}
Ok(next.run(request).await)
}
fn extract_api_key(headers: &HeaderMap) -> Option<String> {
if let Some(auth) = headers.get("authorization")
&& let Ok(value) = auth.to_str()
{
if let Some(key) = value.strip_prefix("Bearer ") {
return Some(key.trim().to_string());
}
if value.starts_with("asg_") {
return Some(value.trim().to_string());
}
}
if let Some(key) = headers.get("x-api-key")
&& let Ok(value) = key.to_str()
{
return Some(value.trim().to_string());
}
None
}
fn is_key_live(k: &ApiKey) -> bool {
if !k.enabled {
return false;
}
if let Some(exp) = k.expires_at
&& Utc::now() > exp
{
return false;
}
true
}
fn generate_key() -> String {
let mut bytes = [0u8; 32];
if rand::rngs::OsRng.try_fill_bytes(&mut bytes).is_err() {
rand::rng().fill_bytes(&mut bytes);
}
let mut s = String::with_capacity(4 + 64);
s.push_str("asg_");
for b in &bytes {
use std::fmt::Write;
let _ = write!(&mut s, "{:02x}", b);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_key_matches_asg_hex64() {
let k = generate_key();
assert!(k.starts_with("asg_"), "missing asg_ prefix: {}", k);
let hex = &k[4..];
assert_eq!(hex.len(), 64, "hex must be 64 chars: {}", k);
assert!(
hex.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
"hex must be lowercase: {}",
k
);
}
#[test]
fn generated_keys_are_unique() {
let a = generate_key();
let b = generate_key();
assert_ne!(a, b);
}
#[test]
fn is_key_live_respects_enabled_and_expiry() {
let mut k = ApiKey {
key: "asg_x".into(),
tenant_id: "t".into(),
name: "n".into(),
plan: "free".into(),
enabled: true,
created_at: Utc::now().to_rfc3339(),
namespace_id: None,
commit_agent_id: None,
can_migrate: false,
is_admin: false,
expires_at: None,
last_used_at: None,
};
assert!(is_key_live(&k));
k.enabled = false;
assert!(!is_key_live(&k));
k.enabled = true;
k.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
assert!(!is_key_live(&k));
k.expires_at = Some(Utc::now() + chrono::Duration::hours(1));
assert!(is_key_live(&k));
}
}