use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use axum::{
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 namespace_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 {
#[serde(default)]
pub id: String,
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, serde::Serialize, serde::Deserialize)]
pub struct StoredKey {
pub id: String,
pub key_hash: String,
pub key_preview: 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>>,
}
impl StoredKey {
fn from_api_key(key: &ApiKey, id: String) -> Self {
Self {
id,
key_hash: hash_key(&key.key),
key_preview: preview(&key.key),
tenant_id: key.tenant_id.clone(),
name: key.name.clone(),
plan: key.plan.clone(),
enabled: key.enabled,
created_at: key.created_at.clone(),
commit_agent_id: key.commit_agent_id.clone(),
namespace_id: key.namespace_id.clone(),
can_migrate: key.can_migrate,
is_admin: key.is_admin,
expires_at: key.expires_at,
last_used_at: key.last_used_at,
}
}
fn issue(&self, raw: String) -> ApiKey {
ApiKey {
id: self.id.clone(),
key: raw,
tenant_id: self.tenant_id.clone(),
name: self.name.clone(),
plan: self.plan.clone(),
enabled: self.enabled,
created_at: self.created_at.clone(),
commit_agent_id: self.commit_agent_id.clone(),
namespace_id: self.namespace_id.clone(),
can_migrate: self.can_migrate,
is_admin: self.is_admin,
expires_at: self.expires_at,
last_used_at: self.last_used_at,
}
}
fn is_live(&self) -> bool {
self.enabled && self.expires_at.is_none_or(|exp| Utc::now() <= exp)
}
}
const KEYS_FILE_VERSION: u32 = 2;
#[derive(serde::Serialize, serde::Deserialize)]
struct KeysFile {
version: u32,
keys: Vec<StoredKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyStoreError(pub String);
impl std::fmt::Display for KeyStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for KeyStoreError {}
pub enum KeyRef<'a> {
Id(&'a str),
Key(&'a str),
}
#[derive(Debug, Clone, Default)]
pub struct AuthContext {
pub commit_agent_id: Option<String>,
pub can_migrate: bool,
pub key_id: Option<String>,
pub namespace_id: Option<String>,
}
pub struct TenantManager {
keys: RwLock<HashMap<String, StoredKey>>,
namespace_repos: RwLock<HashMap<String, Arc<Repository>>>,
default_repo: Arc<Repository>,
tenant_id: String,
auth_enabled: bool,
keys_file: Option<PathBuf>,
}
impl TenantManager {
pub fn single_tenant(repo: Arc<Repository>) -> Arc<Self> {
Arc::new(Self {
keys: RwLock::new(HashMap::new()),
namespace_repos: RwLock::new(HashMap::new()),
default_repo: repo,
tenant_id: DEFAULT_TENANT.to_string(),
auth_enabled: false,
keys_file: None,
})
}
pub fn multi_tenant(
default_repo: Arc<Repository>,
keys_file: Option<&str>,
) -> Result<Arc<Self>, KeyStoreError> {
Self::multi_tenant_for(default_repo, keys_file, DEFAULT_TENANT)
}
pub fn multi_tenant_for(
default_repo: Arc<Repository>,
keys_file: Option<&str>,
tenant_id: &str,
) -> Result<Arc<Self>, KeyStoreError> {
let keys_file = keys_file.map(PathBuf::from);
let (keys, migrated) = match &keys_file {
Some(path) => load_keys(path)?,
None => (HashMap::new(), false),
};
let mgr = Arc::new(Self {
keys: RwLock::new(keys),
namespace_repos: RwLock::new(HashMap::new()),
default_repo,
tenant_id: tenant_id.to_string(),
auth_enabled: true,
keys_file,
});
if let Some(path) = &mgr.keys_file {
if migrated {
let keys = mgr.keys.read().unwrap_or_else(|e| e.into_inner());
match write_keys_file(path, &keys) {
Ok(()) => tracing::info!(
path = %path.display(),
keys = keys.len(),
"keys file now stores hashes instead of keys"
),
Err(e) => eprintln!(
"WARNING: {}: still holds raw keys, and couldn't be rewritten with \
hashes ({e}). Keys work; fix the file's permissions so it can be.",
path.display()
),
}
}
warn_if_readable_by_others(path);
}
Ok(mgr)
}
fn update<T>(
&self,
change: impl FnOnce(&mut HashMap<String, StoredKey>) -> T,
) -> Result<T, KeyStoreError> {
let mut keys = self.keys.write().unwrap_or_else(|e| e.into_inner());
let mut next = keys.clone();
let out = change(&mut next);
if let Some(path) = &self.keys_file {
write_keys_file(path, &next)?;
}
*keys = next;
Ok(out)
}
fn live(&self, api_key: &str) -> Option<StoredKey> {
let keys = self.keys.read().ok()?;
keys.get(&hash_key(api_key))
.filter(|k| k.is_live())
.cloned()
}
pub fn register_key(&self, api_key: ApiKey) -> Result<String, KeyStoreError> {
let id = if api_key.id.is_empty() {
generate_key_id()
} else {
api_key.id.clone()
};
let stored = StoredKey::from_api_key(&api_key, id.clone());
self.update(|keys| {
keys.insert(stored.key_hash.clone(), stored);
})?;
Ok(id)
}
#[allow(dead_code)]
pub fn create_key(
&self,
tenant_id: &str,
name: &str,
plan: &str,
) -> Result<ApiKey, KeyStoreError> {
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,
) -> Result<ApiKey, KeyStoreError> {
let mut api_key = ApiKey {
id: generate_key_id(),
key: generate_key(),
tenant_id: tenant_id.to_string(),
name: name.to_string(),
plan: plan.to_string(),
enabled: true,
created_at: Utc::now().to_rfc3339(),
namespace_id: opts.namespace_id,
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,
};
api_key.id = self.register_key(api_key.clone())?;
Ok(api_key)
}
pub fn key_id(&self, key_ref: KeyRef<'_>) -> Option<String> {
let keys = self.keys.read().ok()?;
match key_ref {
KeyRef::Id(id) => keys.values().find(|k| k.id == id).map(|k| k.id.clone()),
KeyRef::Key(raw) => keys.get(&hash_key(raw)).map(|k| k.id.clone()),
}
}
pub fn rotate_key(&self, key_id: &str) -> Result<Option<ApiKey>, KeyStoreError> {
let raw = generate_key();
let new_id = generate_key_id();
let rotated = self.update(|keys| {
let old = keys.values_mut().find(|k| k.id == key_id)?;
old.enabled = false;
let mut new = old.clone();
new.id = new_id;
new.key_hash = hash_key(&raw);
new.key_preview = preview(&raw);
new.enabled = true;
new.created_at = Utc::now().to_rfc3339();
new.last_used_at = None;
keys.insert(new.key_hash.clone(), new.clone());
Some(new)
})?;
Ok(rotated.map(|new| new.issue(raw)))
}
pub fn register_admin_key(&self, key: String, name: &str) -> Result<bool, KeyStoreError> {
if self.key_id(KeyRef::Key(&key)).is_some() {
return Ok(false);
}
self.register_key(ApiKey {
id: String::new(),
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,
})?;
Ok(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;
}
api_key
.and_then(|k| self.live(k))
.is_some_and(|k| k.is_admin)
}
pub fn get_agent_id(&self, api_key: &str) -> Option<String> {
self.live(api_key)?.commit_agent_id
}
pub fn can_migrate(&self, api_key: &str) -> bool {
if !self.auth_enabled {
return true;
}
self.live(api_key).is_some_and(|k| k.can_migrate)
}
pub fn get_namespace_id(&self, api_key: &str) -> Option<String> {
self.live(api_key)?.namespace_id
}
pub fn live_key_id(&self, api_key: &str) -> Option<String> {
self.live(api_key).map(|k| k.id)
}
pub fn default_repo(&self) -> &Arc<Repository> {
&self.default_repo
}
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());
let mut listed: Vec<&StoredKey> = keys.values().collect();
listed.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
listed
.into_iter()
.map(|k| {
serde_json::json!({
"id": k.id,
"key_preview": k.key_preview,
"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,
"namespace_id": k.namespace_id,
"can_migrate": k.can_migrate,
"is_admin": k.is_admin,
"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_id: &str) -> Result<bool, KeyStoreError> {
self.update(|keys| {
keys.values_mut()
.find(|k| k.id == key_id)
.map(|k| k.enabled = false)
.is_some()
})
}
pub fn resolve_tenant(&self, api_key: &str) -> Option<String> {
let tenant = self.live(api_key)?.tenant_id;
if let Ok(mut keys) = self.keys.write()
&& let Some(k) = keys.get_mut(&hash_key(api_key))
{
k.last_used_at = Some(Utc::now());
}
Some(tenant)
}
pub fn tenant_id(&self) -> &str {
&self.tenant_id
}
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 = self.resolve_tenant(key).ok_or(AuthError::InvalidKey)?;
let stored = self.live(key).ok_or(AuthError::InvalidKey)?;
if !stored.is_admin && tenant != self.tenant_id {
return Err(AuthError::WrongTenant {
key_tenant: tenant,
server_tenant: self.tenant_id.clone(),
});
}
match stored.namespace_id.as_deref() {
None => Ok(self.default_repo.clone()),
Some(ns) => self.namespace_repo(ns),
}
}
fn namespace_repo(&self, ns: &str) -> Result<Arc<Repository>, AuthError> {
if let Some(repo) = self
.namespace_repos
.read()
.unwrap_or_else(|e| e.into_inner())
.get(ns)
{
return Ok(Arc::clone(repo));
}
let namespace =
agentstategraph_core::Namespace::new(ns).map_err(|_| AuthError::BadKeyNamespace)?;
let repo = Arc::new(
self.default_repo
.fork_namespace(namespace)
.with_branch_refs_only(),
);
Ok(Arc::clone(
self.namespace_repos
.write()
.unwrap_or_else(|e| e.into_inner())
.entry(ns.to_string())
.or_insert(repo),
))
}
}
fn load_keys(path: &Path) -> Result<(HashMap<String, StoredKey>, bool), KeyStoreError> {
let data = match std::fs::read_to_string(path) {
Ok(data) => data,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok((HashMap::new(), false));
}
Err(e) => {
return Err(KeyStoreError(format!(
"can't read keys file {}: {e}",
path.display()
)));
}
};
let unreadable = |what: &str, e: serde_json::Error| {
KeyStoreError(format!(
"keys file {} isn't a valid {what}: {e}. Fix or move it; the server won't \
start without its keys, or replace it.",
path.display()
))
};
if data.trim_start().starts_with('[') {
let legacy: Vec<ApiKey> =
serde_json::from_str(&data).map_err(|e| unreadable("version 1 keys file", e))?;
let keys = legacy
.iter()
.map(|k| {
let id = if k.id.is_empty() {
generate_key_id()
} else {
k.id.clone()
};
let stored = StoredKey::from_api_key(k, id);
(stored.key_hash.clone(), stored)
})
.collect();
return Ok((keys, true));
}
let file: KeysFile = serde_json::from_str(&data).map_err(|e| unreadable("keys file", e))?;
if file.version > KEYS_FILE_VERSION {
return Err(KeyStoreError(format!(
"keys file {} is version {}, written by a newer server; this one reads up to {}",
path.display(),
file.version,
KEYS_FILE_VERSION
)));
}
Ok((
file.keys
.into_iter()
.map(|k| (k.key_hash.clone(), k))
.collect(),
false,
))
}
fn write_keys_file(path: &Path, keys: &HashMap<String, StoredKey>) -> Result<(), KeyStoreError> {
use std::io::Write;
let failed = |what: &str, e: &dyn std::fmt::Display| {
KeyStoreError(format!(
"couldn't save keys file {} ({what}): {e}",
path.display()
))
};
let mut sorted: Vec<StoredKey> = keys.values().cloned().collect();
sorted.sort_by(|a, b| a.id.cmp(&b.id));
let data = serde_json::to_vec_pretty(&KeysFile {
version: KEYS_FILE_VERSION,
keys: sorted,
})
.map_err(|e| failed("serialize", &e))?;
let dir = match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => Path::new("."),
};
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "keys".into());
let tmp = dir.join(format!(".{name}.{}.tmp", &generate_key_id()[4..]));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let written = options.open(&tmp).and_then(|mut f| {
f.write_all(&data)?;
f.sync_all()
});
if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, path)) {
let _ = std::fs::remove_file(&tmp);
return Err(failed("write", &e));
}
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
Ok(())
}
#[cfg(unix)]
fn warn_if_readable_by_others(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path)
&& meta.permissions().mode() & 0o077 != 0
{
eprintln!(
"WARNING: keys file {} can be read by other users (mode {:o}); it is \
tightened to owner-only the next time keys change.",
path.display(),
meta.permissions().mode() & 0o777
);
}
}
#[cfg(not(unix))]
fn warn_if_readable_by_others(_path: &Path) {}
fn hash_key(raw: &str) -> String {
use sha2::Digest;
hex::encode(sha2::Sha256::digest(raw.as_bytes()))
}
fn preview(raw: &str) -> String {
if raw.len() > 12 && raw.is_char_boundary(8) && raw.is_char_boundary(raw.len() - 4) {
format!("{}...{}", &raw[..8], &raw[raw.len() - 4..])
} else {
"***".to_string()
}
}
fn generate_key_id() -> String {
let mut bytes = [0u8; 8];
rand::rng().fill_bytes(&mut bytes);
format!("key_{}", hex::encode(bytes))
}
pub const DEFAULT_TENANT: &str = "default";
#[derive(Debug)]
pub enum AuthError {
MissingKey,
InvalidKey,
NotAdmin,
WrongTenant {
key_tenant: String,
server_tenant: String,
},
BadKeyNamespace,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let reason = match self {
AuthError::MissingKey | AuthError::InvalidKey => "unauthenticated",
_ => "forbidden",
};
let (status, msg) = match self {
AuthError::MissingKey => (
StatusCode::UNAUTHORIZED,
"Missing API key. Pass it as: Authorization: Bearer asg_...".to_string(),
),
AuthError::InvalidKey => (
StatusCode::UNAUTHORIZED,
"Invalid or revoked API key.".to_string(),
),
AuthError::NotAdmin => (
StatusCode::FORBIDDEN,
"Admin privilege required for this endpoint.".to_string(),
),
AuthError::WrongTenant {
key_tenant,
server_tenant,
} => (
StatusCode::FORBIDDEN,
format!(
"This key belongs to tenant '{key_tenant}', but this server serves tenant \
'{server_tenant}'. Use a key for '{server_tenant}', or start the server \
with --tenant {key_tenant}."
),
),
AuthError::BadKeyNamespace => (
StatusCode::FORBIDDEN,
"This key is bound to an invalid namespace name.".to_string(),
),
};
crate::http::rejection(status, reason, msg)
}
}
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_id = api_key
.as_deref()
.and_then(|k| tenant_mgr.key_id(KeyRef::Key(k)));
if let Some(id) = &key_id {
tracing::Span::current().record("asg.api_key.id", id.as_str());
}
AuthContext {
commit_agent_id,
can_migrate,
key_id,
namespace_id,
}
} else {
AuthContext {
commit_agent_id: None,
can_migrate: true,
key_id: 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 let Some(id) = tenant_mgr.key_id(KeyRef::Key(key)) {
tracing::Span::current().record("asg.api_key.id", id.as_str());
}
if !tenant_mgr.is_admin(Some(key)) {
return Err(AuthError::NotAdmin);
}
Ok(next.run(request).await)
}
pub(crate) 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
}
#[cfg(test)]
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 {
id: String::new(),
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));
}
}