Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-05-14 18:14:20 -04:00
commit b52bfc08e0
7 changed files with 171 additions and 7 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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,
}

View file

@ -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

View file

@ -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?;

View file

@ -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)
}

View file

@ -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(),