feat(api): per-agent bearer auth on agent CRUD, conversation, and stream endpoints
- Adds owner_seed_id column to agents table, stamped from instance Ed25519 seed on agent creation (Path B ownership model) - Splits routes into public (list/create agents, health) and protected (update/delete agent, get conversation, stream messages) with per-agent bearer token middleware - Factors verify_token() helper from existing memory-route middleware; adds require_agent_token() and require_conversation_token() wrappers - RemoteBackend loads per-agent token from disk and sends Authorization header on protected requests; ChatState recreates backend with token after discovering agent_id
This commit is contained in:
parent
fec3aa625d
commit
b52bfc08e0
7 changed files with 171 additions and 7 deletions
|
|
@ -156,6 +156,83 @@ pub async fn require_token(
|
|||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Verify a bearer token against a given agent_id, respecting config and loopback.
|
||||
///
|
||||
/// Shared by all middleware variants. Returns `Ok(())` on pass, `Err(401)` on fail.
|
||||
pub async fn verify_token(
|
||||
server: &SouveraineServer,
|
||||
agent_id: &str,
|
||||
headers: &HeaderMap,
|
||||
remote: Option<IpAddr>,
|
||||
) -> Result<(), StatusCode> {
|
||||
let cfg = server.app_config.read().await;
|
||||
let auth_required = cfg.server.auth.required;
|
||||
let allow_loopback = cfg.server.auth.allow_loopback;
|
||||
drop(cfg);
|
||||
|
||||
if !auth_required {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if allow_loopback && is_loopback(remote) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let presented = extract_bearer(headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
let server_data_dir = {
|
||||
let cfg = server.config.read().await;
|
||||
cfg.data_dir.clone()
|
||||
};
|
||||
|
||||
let expected = match read_token(&server_data_dir, agent_id).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Err(StatusCode::UNAUTHORIZED),
|
||||
};
|
||||
if !ct_eq(&presented, &expected) {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Auth middleware for agent CRUD routes (PATCH/DELETE /v1/agents/:id).
|
||||
///
|
||||
/// Uses the same per-agent token as the memory routes. Applied as a
|
||||
/// route_layer on agent update/delete so only the agent owner can modify
|
||||
/// or remove an agent.
|
||||
pub async fn require_agent_token(
|
||||
State(server): State<Arc<SouveraineServer>>,
|
||||
Path(agent_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
remote: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
|
||||
req: axum::http::Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
verify_token(&server, &agent_id, &headers, remote.map(|ci| ci.0.ip())).await?;
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Auth middleware for conversation routes (GET /v1/conversations/:id,
|
||||
/// POST /v1/conversations/:id/messages).
|
||||
///
|
||||
/// Resolves the agent_id from the conversation session, then applies
|
||||
/// the same per-agent token check.
|
||||
pub async fn require_conversation_token(
|
||||
State(server): State<Arc<SouveraineServer>>,
|
||||
Path(conversation_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
remote: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
|
||||
req: axum::http::Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let agent_id = {
|
||||
let session = server.sessions.get(&conversation_id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
session.agent_id.clone()
|
||||
};
|
||||
verify_token(&server, &agent_id, &headers, remote.map(|ci| ci.0.ip())).await?;
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Lightweight in-memory cache for tokens that have been verified recently.
|
||||
/// Optional optimization; the on-disk read is fast enough for now, but this
|
||||
/// is the seam if/when we need it.
|
||||
|
|
|
|||
|
|
@ -12,19 +12,33 @@ pub mod handlers;
|
|||
pub mod models;
|
||||
|
||||
pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
|
||||
// Public routes — no auth required (agents, conversations, health).
|
||||
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health).
|
||||
let public_routes = Router::new()
|
||||
.route("/v1/agents", get(handlers::list_agents).post(handlers::create_agent))
|
||||
.route("/v1/conversations", get(handlers::list_conversations).post(handlers::create_conversation))
|
||||
.route("/health", get(health_check));
|
||||
|
||||
// Protected agent routes — require per-agent bearer token.
|
||||
let agent_routes = Router::new()
|
||||
.route(
|
||||
"/v1/agents/:id",
|
||||
get(handlers::get_agent)
|
||||
.patch(handlers::update_agent)
|
||||
.delete(handlers::delete_agent),
|
||||
)
|
||||
.route("/v1/conversations", get(handlers::list_conversations).post(handlers::create_conversation))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth::require_agent_token,
|
||||
));
|
||||
|
||||
// Protected conversation routes — require bearer token for the conversation's agent.
|
||||
let conversation_routes = Router::new()
|
||||
.route("/v1/conversations/:id", get(handlers::get_conversation))
|
||||
.route("/v1/conversations/:id/messages", post(handlers::stream_messages))
|
||||
.route("/health", get(health_check));
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth::require_conversation_token,
|
||||
));
|
||||
|
||||
// Memory routes — require per-agent bearer token.
|
||||
//
|
||||
|
|
@ -54,6 +68,8 @@ pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
|
|||
|
||||
Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(agent_routes)
|
||||
.merge(conversation_routes)
|
||||
.merge(memory_routes)
|
||||
.merge(web_routes)
|
||||
.with_state(state)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ pub struct AgentState {
|
|||
pub memory_blocks: Vec<MemoryBlock>,
|
||||
pub tools: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
/// Public key (hex) of the instance that created this agent.
|
||||
/// `None` for agents created before this field existed.
|
||||
pub owner_seed_id: Option<String>,
|
||||
#[serde(rename = "_souveraine")]
|
||||
pub souveraine: SouveraineConfig,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
|
|||
pub struct RemoteBackend {
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
/// Optional bearer token. When set, sent as `Authorization: Bearer <token>`
|
||||
/// on all requests. Loaded from the per-agent token file at construction
|
||||
/// when the agent_id is known.
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl RemoteBackend {
|
||||
|
|
@ -28,7 +32,40 @@ impl RemoteBackend {
|
|||
.connect_timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.expect("reqwest client");
|
||||
Self { base_url: url, client }
|
||||
Self { base_url: url, client, token: None }
|
||||
}
|
||||
|
||||
/// Create a RemoteBackend that sends bearer tokens for the given agent_id.
|
||||
/// Loads the token from the standard on-disk location.
|
||||
pub fn with_agent(base_url: impl Into<String>, agent_id: &str) -> Self {
|
||||
let mut this = Self::new(base_url);
|
||||
this.load_token(agent_id);
|
||||
this
|
||||
}
|
||||
|
||||
/// Try to load the per-agent bearer token from disk. Silently leaves
|
||||
/// `token` as None if the token file doesn't exist or is unreadable —
|
||||
/// loopback bypass will handle the common case.
|
||||
fn load_token(&mut self, agent_id: &str) {
|
||||
let token_path = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".souveraine")
|
||||
.join("server")
|
||||
.join("agents")
|
||||
.join(agent_id)
|
||||
.join("api_token");
|
||||
if let Ok(content) = std::fs::read_to_string(&token_path) {
|
||||
self.token = Some(content.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the Authorization header if a token is stored.
|
||||
fn auth_req(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(ref token) = self.token {
|
||||
req.bearer_auth(token)
|
||||
} else {
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
|
|
@ -123,8 +160,10 @@ impl Backend for RemoteBackend {
|
|||
"stream": true,
|
||||
});
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.url(&format!("/v1/conversations/{}/messages", conversation_id)))
|
||||
.auth_req(
|
||||
self.client
|
||||
.post(self.url(&format!("/v1/conversations/{}/messages", conversation_id))),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ pub struct AgentInventory {
|
|||
subconscious_dir: PathBuf,
|
||||
db: SqlitePool,
|
||||
cache: DashMap<String, AgentState>,
|
||||
/// Public key (hex) of this Souveraine instance, loaded at startup.
|
||||
/// Used as `owner_seed_id` on newly created agents.
|
||||
instance_seed_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentInventory {
|
||||
|
|
@ -49,9 +52,25 @@ impl AgentInventory {
|
|||
subconscious_dir,
|
||||
db,
|
||||
cache: DashMap::new(),
|
||||
instance_seed_id: Self::load_instance_seed_id(&souveraine_root),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the instance-level seed identity. This is the owner identity for
|
||||
/// all agents created by this Souveraine instance. Falls back to None
|
||||
/// silently — agents created without an owner can still be managed via
|
||||
/// per-agent tokens.
|
||||
fn load_instance_seed_id(souveraine_root: &PathBuf) -> Option<String> {
|
||||
let seed_dir = crate::core::identity::SeedId::default_dir(souveraine_root);
|
||||
match crate::core::identity::SeedId::load_or_generate(&seed_dir) {
|
||||
Ok(seed) => Some(seed.public_key_hex()),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "instance seed not available — agent ownership disabled");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a [`MemoryRepo`] rooted at the primary agent's canonical user-side
|
||||
/// memfs (`{memfs_dir}/{agent_id}/memory/`). Used by the consciousness
|
||||
/// engine to write to the same repo CLI tools see.
|
||||
|
|
@ -270,6 +289,7 @@ impl AgentInventory {
|
|||
memory_blocks: blocks,
|
||||
tools: request.tools,
|
||||
tags: request.tags,
|
||||
owner_seed_id: self.instance_seed_id.clone(),
|
||||
souveraine: SouveraineConfig {
|
||||
n1_enabled: true,
|
||||
reflection_enabled: true,
|
||||
|
|
@ -288,7 +308,7 @@ impl AgentInventory {
|
|||
let config_json = serde_json::to_string(&agent.souveraine)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO agents (id, name, description, llm_model, context_window, tags, config_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
|
||||
"INSERT INTO agents (id, name, description, llm_model, context_window, tags, config_json, owner_seed_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
|
||||
)
|
||||
.bind(&uuid)
|
||||
.bind(&agent.name)
|
||||
|
|
@ -297,6 +317,7 @@ impl AgentInventory {
|
|||
.bind(agent.llm_config.context_window as i64)
|
||||
.bind(&tags_json)
|
||||
.bind(&config_json)
|
||||
.bind(&self.instance_seed_id)
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ pub async fn init_database(db_path: &Path) -> anyhow::Result<SqlitePool> {
|
|||
// one running instance. Incremented by the heartbeat tick. Drives the
|
||||
// uptime % on the manager card (capped at 99 in the UI).
|
||||
add_column_if_missing(&pool, "agents", "lifetime_active_seconds", "INTEGER NOT NULL DEFAULT 0").await?;
|
||||
add_column_if_missing(&pool, "agents", "owner_seed_id", "TEXT").await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -442,6 +442,13 @@ impl ChatState {
|
|||
|
||||
let conversation_id = backend.ensure_conversation(&agent.id).await?;
|
||||
|
||||
// Recreate the backend with per-agent token if we're remote
|
||||
let backend: Arc<dyn Backend> = if mode == "remote" {
|
||||
Arc::new(crate::backend::RemoteBackend::with_agent(&url, &agent.id))
|
||||
} else {
|
||||
backend
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
backend,
|
||||
mode: mode.to_string(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue