use crate::sql::{Auto, ExecError, Pool};
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_agents",
display = "name",
admin(
list_display = "name, user_id, active, secret_prefix, created_at",
search_fields = "name",
ordering = "name",
readonly_fields = "secret_prefix, secret_hash, created_at, secret_rotated_at",
)
)]
pub struct Agent {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 150, unique)]
pub name: String,
#[rustango(max_length = 16, unique)]
pub secret_prefix: String,
#[rustango(max_length = 255)]
pub secret_hash: String,
pub active: bool,
#[rustango(auto_now_add)]
pub created_at: Auto<chrono::DateTime<chrono::Utc>>,
pub secret_rotated_at: Option<chrono::DateTime<chrono::Utc>>,
pub user_id: Option<i64>,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
pub struct AgentSecret {
pub agent: Agent,
pub token: String,
}
#[derive(Debug, thiserror::Error)]
pub enum AgentError {
#[error("agent `{0}` already exists in this tenant")]
Duplicate(String),
#[error("agent `{0}` not found in this tenant")]
NotFound(String),
#[error("secret generation failed: {0}")]
Secret(String),
#[error(transparent)]
Db(#[from] ExecError),
#[error(transparent)]
Driver(#[from] sqlx::Error),
#[error(transparent)]
Tenancy(#[from] super::error::TenancyError),
}
fn generate_credential() -> Result<(String, String, String), AgentError> {
use rand::rngs::OsRng;
use rand::RngCore;
let mut prefix_bytes = [0u8; 4];
OsRng.fill_bytes(&mut prefix_bytes);
let prefix = to_hex(&prefix_bytes);
let mut secret_bytes = [0u8; 16];
OsRng.fill_bytes(&mut secret_bytes);
let secret = to_hex(&secret_bytes);
let hash = super::password::hash(&secret).map_err(|e| AgentError::Secret(e.to_string()))?;
Ok((format!("{prefix}.{secret}"), prefix, hash))
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub async fn create_agent_pool(pool: &Pool, name: &str) -> Result<AgentSecret, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let existing: Vec<Agent> = Agent::objects()
.where_(Agent::name.eq(name))
.limit(1)
.fetch(pool)
.await?;
if !existing.is_empty() {
return Err(AgentError::Duplicate(name.to_owned()));
}
let (token, prefix, hash) = generate_credential()?;
let mut agent = Agent {
id: Auto::default(),
name: name.to_owned(),
secret_prefix: prefix,
secret_hash: hash,
active: true,
created_at: Auto::default(),
secret_rotated_at: None,
user_id: None,
data: serde_json::json!({}),
};
agent.insert_pool(pool).await?;
Ok(AgentSecret { agent, token })
}
pub async fn rotate_agent_secret_pool(pool: &Pool, name: &str) -> Result<AgentSecret, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let mut agent: Agent = Agent::objects()
.where_(Agent::name.eq(name))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(name.to_owned()))?;
let (token, prefix, hash) = generate_credential()?;
agent.secret_prefix = prefix;
agent.secret_hash = hash;
agent.secret_rotated_at = Some(chrono::Utc::now());
agent.save_pool(pool).await?;
Ok(AgentSecret { agent, token })
}
pub async fn list_agents_pool(pool: &Pool) -> Result<Vec<Agent>, AgentError> {
use crate::sql::FetcherPool as _;
let agents = Agent::objects()
.order_by(&[("name", false)])
.fetch(pool)
.await?;
Ok(agents)
}
pub async fn authenticate_agent_pool(
pool: &Pool,
name: &str,
secret: &str,
) -> Result<Option<Agent>, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let secret_half = secret.rsplit('.').next().unwrap_or(secret);
let Some(agent) = Agent::objects()
.where_(Agent::name.eq(name))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.filter(|a| a.active)
else {
super::password::verify_dummy(secret_half);
return Ok(None);
};
match super::password::verify(secret_half, &agent.secret_hash) {
Ok(true) => Ok(Some(agent)),
_ => Ok(None),
}
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_agent_skills",
display = "codename",
admin(
list_display = "codename, name, description",
search_fields = "codename, name",
ordering = "codename",
)
)]
pub struct AgentSkill {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 100, unique)]
pub codename: String,
#[rustango(max_length = 150)]
pub name: String,
#[rustango(max_length = 500)]
pub description: String,
pub instructions: String,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(table = "rustango_agent_skill_tools")]
pub struct AgentSkillTool {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(fk = "rustango_agent_skills", on = "id", on_delete = "cascade")]
pub skill_id: i64,
#[rustango(max_length = 150)]
pub tool_name: String,
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_agent_grants",
admin(list_display = "agent_id, skill_id", ordering = "agent_id")
)]
pub struct AgentGrant {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(fk = "rustango_agents", on = "id", on_delete = "cascade")]
pub agent_id: i64,
#[rustango(fk = "rustango_agent_skills", on = "id", on_delete = "cascade")]
pub skill_id: i64,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_agent_skill_permissions",
admin(list_display = "skill_id, permission_codename", ordering = "skill_id")
)]
pub struct AgentSkillPermission {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(fk = "rustango_agent_skills", on = "id", on_delete = "cascade")]
pub skill_id: i64,
#[rustango(max_length = 150)]
pub permission_codename: String,
}
pub async fn create_skill_pool(
pool: &Pool,
codename: &str,
name: &str,
description: &str,
instructions: &str,
tools: &[String],
) -> Result<AgentSkill, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let existing: Vec<AgentSkill> = AgentSkill::objects()
.where_(AgentSkill::codename.eq(codename))
.limit(1)
.fetch(pool)
.await?;
if !existing.is_empty() {
return Err(AgentError::Duplicate(codename.to_owned()));
}
let mut skill = AgentSkill {
id: Auto::default(),
codename: codename.to_owned(),
name: name.to_owned(),
description: description.to_owned(),
instructions: instructions.to_owned(),
data: serde_json::json!({}),
};
skill.insert_pool(pool).await?;
let skill_id = skill.id.get().copied().unwrap_or_default();
for tool in tools {
let mut row = AgentSkillTool {
id: Auto::default(),
skill_id,
tool_name: tool.clone(),
};
row.insert_pool(pool).await?;
}
Ok(skill)
}
pub async fn list_skills_pool(pool: &Pool) -> Result<Vec<AgentSkill>, AgentError> {
use crate::sql::FetcherPool as _;
Ok(AgentSkill::objects()
.order_by(&[("codename", false)])
.fetch(pool)
.await?)
}
pub async fn grant_skill_pool(
pool: &Pool,
slug: &str,
agent_name: &str,
skill_codename: &str,
) -> Result<(), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let (agent_id, skill_id) = resolve_ids(pool, agent_name, skill_codename).await?;
let existing: Vec<AgentGrant> = AgentGrant::objects()
.where_(AgentGrant::agent_id.eq(agent_id))
.where_(AgentGrant::skill_id.eq(skill_id))
.limit(1)
.fetch(pool)
.await?;
if existing.is_empty() {
let mut grant = AgentGrant {
id: Auto::default(),
agent_id,
skill_id,
data: serde_json::json!({}),
};
grant.insert_pool(pool).await?;
notify_grants_changed(slug, agent_id);
}
Ok(())
}
pub async fn revoke_skill_pool(
pool: &Pool,
slug: &str,
agent_name: &str,
skill_codename: &str,
) -> Result<(), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let (agent_id, skill_id) = resolve_ids(pool, agent_name, skill_codename).await?;
let grants: Vec<AgentGrant> = AgentGrant::objects()
.where_(AgentGrant::agent_id.eq(agent_id))
.where_(AgentGrant::skill_id.eq(skill_id))
.fetch(pool)
.await?;
let had_grant = !grants.is_empty();
for grant in grants {
grant.delete_pool(pool).await?;
}
if had_grant {
notify_grants_changed(slug, agent_id);
}
Ok(())
}
#[allow(unused_variables)]
fn notify_grants_changed(slug: &str, agent_id: i64) {
#[cfg(feature = "mcp")]
{
crate::mcp::notify_tools_list_changed(slug, Some(agent_id));
crate::mcp::notify_prompts_list_changed(slug, Some(agent_id));
crate::mcp::notify_resources_list_changed(slug, Some(agent_id));
}
}
async fn resolve_ids(
pool: &Pool,
agent_name: &str,
skill_codename: &str,
) -> Result<(i64, i64), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let agent = Agent::objects()
.where_(Agent::name.eq(agent_name))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(agent_name.to_owned()))?;
let skill = AgentSkill::objects()
.where_(AgentSkill::codename.eq(skill_codename))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(skill_codename.to_owned()))?;
Ok((
agent.id.get().copied().unwrap_or_default(),
skill.id.get().copied().unwrap_or_default(),
))
}
pub async fn resolve_agent_grants_pool(
pool: &Pool,
agent_id: i64,
) -> Result<(Vec<String>, Vec<String>), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let grants: Vec<AgentGrant> = AgentGrant::objects()
.where_(AgentGrant::agent_id.eq(agent_id))
.fetch(pool)
.await?;
let skill_ids: Vec<i64> = grants.iter().map(|g| g.skill_id).collect();
if skill_ids.is_empty() {
return Ok((vec![], vec![]));
}
let skills: Vec<AgentSkill> = AgentSkill::objects()
.where_(AgentSkill::id.is_in(skill_ids.clone()))
.fetch(pool)
.await?;
let skill_codenames: Vec<String> = skills.into_iter().map(|s| s.codename).collect();
let skill_tools: Vec<AgentSkillTool> = AgentSkillTool::objects()
.where_(AgentSkillTool::skill_id.is_in(skill_ids))
.fetch(pool)
.await?;
let mut tools: Vec<String> = Vec::new();
for st in skill_tools {
if !tools.contains(&st.tool_name) {
tools.push(st.tool_name);
}
}
Ok((skill_codenames, tools))
}
pub async fn map_skill_to_permission_pool(
pool: &Pool,
skill_codename: &str,
permission_codename: &str,
) -> Result<(), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let skill_id = AgentSkill::objects()
.where_(AgentSkill::codename.eq(skill_codename))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(skill_codename.to_owned()))?
.id
.get()
.copied()
.unwrap_or_default();
let existing: Vec<AgentSkillPermission> = AgentSkillPermission::objects()
.where_(AgentSkillPermission::skill_id.eq(skill_id))
.where_(AgentSkillPermission::permission_codename.eq(permission_codename))
.limit(1)
.fetch(pool)
.await?;
if existing.is_empty() {
let mut row = AgentSkillPermission {
id: Auto::default(),
skill_id,
permission_codename: permission_codename.to_owned(),
};
row.insert_pool(pool).await?;
}
Ok(())
}
pub async fn unmap_skill_from_permission_pool(
pool: &Pool,
skill_codename: &str,
permission_codename: &str,
) -> Result<(), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let Some(skill_id) = AgentSkill::objects()
.where_(AgentSkill::codename.eq(skill_codename))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.and_then(|s| s.id.get().copied())
else {
return Err(AgentError::NotFound(skill_codename.to_owned()));
};
let rows: Vec<AgentSkillPermission> = AgentSkillPermission::objects()
.where_(AgentSkillPermission::skill_id.eq(skill_id))
.where_(AgentSkillPermission::permission_codename.eq(permission_codename))
.fetch(pool)
.await?;
for row in rows {
row.delete_pool(pool).await?;
}
Ok(())
}
async fn user_entitled_skill_ids(
pool: &Pool,
user_id: i64,
) -> Result<Option<Vec<i64>>, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let is_superuser = crate::tenancy::User::objects()
.filter("id", user_id)
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.is_some_and(|u| u.is_superuser);
if is_superuser {
return Ok(None);
}
let perms = super::user_permissions_pool(user_id, pool).await?;
if perms.is_empty() {
return Ok(Some(Vec::new()));
}
let mapped: Vec<AgentSkillPermission> = AgentSkillPermission::objects()
.where_(AgentSkillPermission::permission_codename.is_in(perms))
.fetch(pool)
.await?;
let mut ids: Vec<i64> = Vec::new();
for m in mapped {
if !ids.contains(&m.skill_id) {
ids.push(m.skill_id);
}
}
Ok(Some(ids))
}
pub async fn resolve_user_agent_grants_pool(
pool: &Pool,
agent_id: i64,
user_id: i64,
) -> Result<(Vec<String>, Vec<String>), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let entitled = user_entitled_skill_ids(pool, user_id).await?;
let pinned: Vec<i64> = {
let grants: Vec<AgentGrant> = AgentGrant::objects()
.filter("agent_id", agent_id)
.fetch(pool)
.await?;
let mut ids = Vec::new();
for g in grants {
if !ids.contains(&g.skill_id) {
ids.push(g.skill_id);
}
}
ids
};
let effective: Vec<i64> = match (&entitled, pinned.is_empty()) {
(None, true) => AgentSkill::objects()
.fetch(pool)
.await?
.into_iter()
.filter_map(|s| s.id.get().copied())
.collect(), (None, false) => pinned, (Some(ent), true) => ent.clone(), (Some(ent), false) => pinned.into_iter().filter(|id| ent.contains(id)).collect(),
};
if effective.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let skills: Vec<String> = AgentSkill::objects()
.where_(AgentSkill::id.is_in(effective.clone()))
.fetch(pool)
.await?
.into_iter()
.map(|s| s.codename)
.collect();
let mut tools: Vec<String> = Vec::new();
for st in AgentSkillTool::objects()
.where_(AgentSkillTool::skill_id.is_in(effective))
.fetch(pool)
.await?
{
if !tools.contains(&st.tool_name) {
tools.push(st.tool_name);
}
}
Ok((skills, tools))
}
async fn unique_key_name(pool: &Pool, user_id: i64) -> Result<String, AgentError> {
use crate::sql::FetcherPool as _;
use rand::rngs::OsRng;
use rand::RngCore;
for _ in 0..6 {
let mut b = [0u8; 5];
OsRng.fill_bytes(&mut b);
let name = format!("uk_{user_id}_{}", to_hex(&b));
let taken: Vec<Agent> = Agent::objects()
.filter("name", name.clone())
.limit(1)
.fetch(pool)
.await?;
if taken.is_empty() {
return Ok(name);
}
}
Err(AgentError::Secret(
"could not allocate a unique key name".to_owned(),
))
}
pub async fn create_user_key_pool(
pool: &Pool,
user_id: i64,
label: &str,
skills: &[String],
) -> Result<AgentSecret, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let mut skill_ids: Vec<i64> = Vec::new();
if !skills.is_empty() {
let entitled = user_entitled_skill_ids(pool, user_id).await?;
for codename in skills {
let skill = AgentSkill::objects()
.where_(AgentSkill::codename.eq(codename.clone()))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(codename.clone()))?;
let sid = skill.id.get().copied().unwrap_or_default();
let entitled_to_it = match &entitled {
None => true, Some(ids) => ids.contains(&sid),
};
if !entitled_to_it {
return Err(AgentError::Tenancy(super::error::TenancyError::Validation(
format!("owner is not entitled to skill `{codename}`"),
)));
}
if !skill_ids.contains(&sid) {
skill_ids.push(sid);
}
}
}
let name = unique_key_name(pool, user_id).await?;
let (token, prefix, hash) = generate_credential()?;
let mut agent = Agent {
id: Auto::default(),
name,
secret_prefix: prefix,
secret_hash: hash,
active: true,
created_at: Auto::default(),
secret_rotated_at: None,
user_id: Some(user_id),
data: serde_json::json!({ "kind": "user_key", "label": label }),
};
agent.insert_pool(pool).await?;
let agent_id = agent.id.get().copied().unwrap_or_default();
for sid in skill_ids {
let mut grant = AgentGrant {
id: Auto::default(),
agent_id,
skill_id: sid,
data: serde_json::json!({}),
};
grant.insert_pool(pool).await?;
}
Ok(AgentSecret { agent, token })
}
pub async fn list_user_keys_pool(pool: &Pool, user_id: i64) -> Result<Vec<Agent>, AgentError> {
use crate::sql::FetcherPool as _;
Ok(Agent::objects()
.filter("user_id", user_id)
.order_by(&[("created_at", true)])
.fetch(pool)
.await?)
}
pub async fn revoke_user_key_pool(
pool: &Pool,
user_id: i64,
agent_id: i64,
) -> Result<(), AgentError> {
use crate::sql::FetcherPool as _;
let agent = Agent::objects()
.filter("id", agent_id)
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.filter(|a| a.user_id == Some(user_id))
.ok_or_else(|| AgentError::NotFound(format!("key #{agent_id}")))?;
let grants: Vec<AgentGrant> = AgentGrant::objects()
.filter("agent_id", agent_id)
.fetch(pool)
.await?;
for g in grants {
g.delete_pool(pool).await?;
}
agent.delete_pool(pool).await?;
Ok(())
}
pub async fn delete_user_keys_pool(pool: &Pool, user_id: i64) -> Result<(), AgentError> {
use crate::sql::FetcherPool as _;
let agents: Vec<Agent> = Agent::objects()
.filter("user_id", user_id)
.fetch(pool)
.await?;
for agent in agents {
let agent_id = agent.id.get().copied().unwrap_or_default();
let grants: Vec<AgentGrant> = AgentGrant::objects()
.filter("agent_id", agent_id)
.fetch(pool)
.await?;
for g in grants {
g.delete_pool(pool).await?;
}
agent.delete_pool(pool).await?;
}
Ok(())
}
pub async fn agent_token_still_valid_pool(
pool: &Pool,
agent_id: i64,
user_id: Option<i64>,
) -> Result<bool, AgentError> {
use crate::sql::FetcherPool as _;
let Some(agent) = Agent::objects()
.filter("id", agent_id)
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
else {
return Ok(false);
};
if !agent.active {
return Ok(false);
}
if let Some(uid) = user_id {
let owner_active = crate::tenancy::User::objects()
.filter("id", uid)
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.is_some_and(|u| u.active);
if !owner_active {
return Ok(false);
}
}
Ok(true)
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(table = "rustango_agent_skill_resources")]
pub struct AgentSkillResource {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(fk = "rustango_agent_skills", on = "id", on_delete = "cascade")]
pub skill_id: i64,
#[rustango(max_length = 500)]
pub resource_uri: String,
#[rustango(max_length = 100)]
pub mime: String,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
pub async fn add_skill_resource_pool(
pool: &Pool,
skill_codename: &str,
uri: &str,
mime: &str,
body: &str,
) -> Result<AgentSkillResource, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let skill_id = AgentSkill::objects()
.where_(AgentSkill::codename.eq(skill_codename))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(skill_codename.to_owned()))?
.id
.get()
.copied()
.unwrap_or_default();
let mut res = AgentSkillResource {
id: Auto::default(),
skill_id,
resource_uri: uri.to_owned(),
mime: mime.to_owned(),
data: serde_json::json!({ "text": body }),
};
res.insert_pool(pool).await?;
Ok(res)
}
pub async fn skills_by_codenames_pool(
pool: &Pool,
codenames: &[String],
) -> Result<Vec<AgentSkill>, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
if codenames.is_empty() {
return Ok(vec![]);
}
Ok(AgentSkill::objects()
.where_(AgentSkill::codename.is_in(codenames.to_vec()))
.order_by(&[("codename", false)])
.fetch(pool)
.await?)
}
pub async fn resources_for_skills_pool(
pool: &Pool,
codenames: &[String],
) -> Result<Vec<AgentSkillResource>, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
if codenames.is_empty() {
return Ok(vec![]);
}
let skill_ids: Vec<i64> = AgentSkill::objects()
.where_(AgentSkill::codename.is_in(codenames.to_vec()))
.fetch(pool)
.await?
.into_iter()
.map(|s| s.id.get().copied().unwrap_or_default())
.collect();
if skill_ids.is_empty() {
return Ok(vec![]);
}
Ok(AgentSkillResource::objects()
.where_(AgentSkillResource::skill_id.is_in(skill_ids))
.fetch(pool)
.await?)
}