diff --git a/crates/ov_cli/README.md b/crates/ov_cli/README.md index 133e4120c..a8382b55b 100644 --- a/crates/ov_cli/README.md +++ b/crates/ov_cli/README.md @@ -25,10 +25,15 @@ Create `~/.openviking/ovcli.conf`: ```json { "url": "http://localhost:1933", - "api_key": "your-api-key" + "api_key": "your-api-key", + "account": "acme", + "user": "alice", + "agent_id": "assistant-1" } ``` +`account` and `user` are optional with a regular user key because the server can derive them from the key. They are recommended when you use `trusted` auth mode or a root key against tenant-scoped APIs. + ## Quick Start ```bash @@ -126,6 +131,9 @@ ov find "API authentication" --threshold 0.7 --limit 5 # Recursive list ov ls viking://resources --recursive +# Temporarily override identity from CLI flags +ov --account acme --user alice --agent-id assistant-2 ls viking:// + # Glob search ov glob "**/*.md" --uri viking://resources diff --git a/crates/ov_cli/src/client.rs b/crates/ov_cli/src/client.rs index 2fa6467f9..5f9c670bf 100644 --- a/crates/ov_cli/src/client.rs +++ b/crates/ov_cli/src/client.rs @@ -4,8 +4,8 @@ use serde_json::Value; use std::fs::File; use std::path::Path; use tempfile::NamedTempFile; -use zip::write::FileOptions; use zip::CompressionMethod; +use zip::write::FileOptions; use crate::error::{Error, Result}; @@ -15,6 +15,8 @@ pub struct HttpClient { http: ReqwestClient, base_url: String, api_key: Option, + account: Option, + user: Option, agent_id: Option, } @@ -24,6 +26,8 @@ impl HttpClient { base_url: impl Into, api_key: Option, agent_id: Option, + account: Option, + user: Option, timeout_secs: f64, ) -> Self { let http = ReqwestClient::builder() @@ -35,6 +39,8 @@ impl HttpClient { http, base_url: base_url.into().trim_end_matches('/').to_string(), api_key, + account, + user, agent_id, } } @@ -51,7 +57,8 @@ impl HttpClient { let temp_file = NamedTempFile::new()?; let file = File::create(temp_file.path())?; let mut zip = zip::ZipWriter::new(file); - let options: FileOptions<'_, ()> = FileOptions::default().compression_method(CompressionMethod::Deflated); + let options: FileOptions<'_, ()> = + FileOptions::default().compression_method(CompressionMethod::Deflated); let walkdir = walkdir::WalkDir::new(dir_path); for entry in walkdir.into_iter().filter_map(|e| e.ok()) { @@ -78,14 +85,13 @@ impl HttpClient { // Read file content let file_content = tokio::fs::read(file_path).await?; - + // Create multipart form - let part = reqwest::multipart::Part::bytes(file_content) - .file_name(file_name.to_string()); - - let part = part.mime_str("application/octet-stream").map_err(|e| { - Error::Network(format!("Failed to set mime type: {}", e)) - })?; + let part = reqwest::multipart::Part::bytes(file_content).file_name(file_name.to_string()); + + let part = part + .mime_str("application/octet-stream") + .map_err(|e| Error::Network(format!("Failed to set mime type: {}", e)))?; let form = reqwest::multipart::Form::new().part("file", part); @@ -126,6 +132,16 @@ impl HttpClient { headers.insert("X-OpenViking-Agent", value); } } + if let Some(account) = &self.account { + if let Ok(value) = reqwest::header::HeaderValue::from_str(account) { + headers.insert("X-OpenViking-Account", value); + } + } + if let Some(user) = &self.user { + if let Ok(value) = reqwest::header::HeaderValue::from_str(user) { + headers.insert("X-OpenViking-User", value); + } + } headers } @@ -224,10 +240,7 @@ impl HttpClient { self.handle_response(response).await } - async fn handle_response( - &self, - response: reqwest::Response, - ) -> Result { + async fn handle_response(&self, response: reqwest::Response) -> Result { let status = response.status(); // Handle empty response (204 No Content, etc.) @@ -248,7 +261,11 @@ impl HttpClient { .and_then(|e| e.get("message")) .and_then(|m| m.as_str()) .map(|s| s.to_string()) - .or_else(|| json.get("detail").and_then(|d| d.as_str()).map(|s| s.to_string())) + .or_else(|| { + json.get("detail") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()) + }) .unwrap_or_else(|| format!("HTTP error {}", status)); return Err(Error::Api(error_msg)); } @@ -296,7 +313,12 @@ impl HttpClient { self.get("/api/v1/content/overview", ¶ms).await } - pub async fn reindex(&self, uri: &str, regenerate: bool, wait: bool) -> Result { + pub async fn reindex( + &self, + uri: &str, + regenerate: bool, + wait: bool, + ) -> Result { let body = serde_json::json!({ "uri": uri, "regenerate": regenerate, @@ -309,7 +331,7 @@ impl HttpClient { pub async fn get_bytes(&self, uri: &str) -> Result> { let url = format!("{}/api/v1/content/download", self.base_url); let params = vec![("uri".to_string(), uri.to_string())]; - + let response = self .http .get(&url) @@ -326,20 +348,22 @@ impl HttpClient { .json() .await .map_err(|e| Error::Network(format!("Failed to parse error response: {}", e))); - + let error_msg = match json_result { - Ok(json) => { - json - .get("error") - .and_then(|e| e.get("message")) - .and_then(|m| m.as_str()) - .map(|s| s.to_string()) - .or_else(|| json.get("detail").and_then(|d| d.as_str()).map(|s| s.to_string())) - .unwrap_or_else(|| format!("HTTP error {}", status)) - } + Ok(json) => json + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + json.get("detail") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| format!("HTTP error {}", status)), Err(_) => format!("HTTP error {}", status), }; - + return Err(Error::Api(error_msg)); } @@ -352,7 +376,16 @@ impl HttpClient { // ============ Filesystem Methods ============ - pub async fn ls(&self, uri: &str, simple: bool, recursive: bool, output: &str, abs_limit: i32, show_all_hidden: bool, node_limit: i32) -> Result { + pub async fn ls( + &self, + uri: &str, + simple: bool, + recursive: bool, + output: &str, + abs_limit: i32, + show_all_hidden: bool, + node_limit: i32, + ) -> Result { let params = vec![ ("uri".to_string(), uri.to_string()), ("simple".to_string(), simple.to_string()), @@ -365,7 +398,15 @@ impl HttpClient { self.get("/api/v1/fs/ls", ¶ms).await } - pub async fn tree(&self, uri: &str, output: &str, abs_limit: i32, show_all_hidden: bool, node_limit: i32, level_limit: i32) -> Result { + pub async fn tree( + &self, + uri: &str, + output: &str, + abs_limit: i32, + show_all_hidden: bool, + node_limit: i32, + level_limit: i32, + ) -> Result { let params = vec![ ("uri".to_string(), uri.to_string()), ("output".to_string(), output.to_string()), @@ -442,7 +483,13 @@ impl HttpClient { self.post("/api/v1/search/search", &body).await } - pub async fn grep(&self, uri: &str, pattern: &str, ignore_case: bool, node_limit: i32) -> Result { + pub async fn grep( + &self, + uri: &str, + pattern: &str, + ignore_case: bool, + node_limit: i32, + ) -> Result { let body = serde_json::json!({ "uri": uri, "pattern": pattern, @@ -452,8 +499,12 @@ impl HttpClient { self.post("/api/v1/search/grep", &body).await } - - pub async fn glob(&self, pattern: &str, uri: &str, node_limit: i32) -> Result { + pub async fn glob( + &self, + pattern: &str, + uri: &str, + node_limit: i32, + ) -> Result { let body = serde_json::json!({ "pattern": pattern, "uri": uri, @@ -726,11 +777,7 @@ impl HttpClient { self.put(&path, &body).await } - pub async fn admin_regenerate_key( - &self, - account_id: &str, - user_id: &str, - ) -> Result { + pub async fn admin_regenerate_key(&self, account_id: &str, user_id: &str) -> Result { let path = format!( "/api/v1/admin/accounts/{}/users/{}/key", account_id, user_id @@ -790,3 +837,47 @@ impl HttpClient { Ok(count) } } + +#[cfg(test)] +mod tests { + use super::HttpClient; + + #[test] + fn build_headers_includes_tenant_identity_headers() { + let client = HttpClient::new( + "http://localhost:1933", + Some("test-key".to_string()), + Some("assistant-1".to_string()), + Some("acme".to_string()), + Some("alice".to_string()), + 5.0, + ); + + let headers = client.build_headers(); + + assert_eq!( + headers + .get("X-API-Key") + .and_then(|value| value.to_str().ok()), + Some("test-key") + ); + assert_eq!( + headers + .get("X-OpenViking-Agent") + .and_then(|value| value.to_str().ok()), + Some("assistant-1") + ); + assert_eq!( + headers + .get("X-OpenViking-Account") + .and_then(|value| value.to_str().ok()), + Some("acme") + ); + assert_eq!( + headers + .get("X-OpenViking-User") + .and_then(|value| value.to_str().ok()), + Some("alice") + ); + } +} diff --git a/crates/ov_cli/src/config.rs b/crates/ov_cli/src/config.rs index e27b3a650..ca1c613f1 100644 --- a/crates/ov_cli/src/config.rs +++ b/crates/ov_cli/src/config.rs @@ -10,6 +10,8 @@ pub struct Config { #[serde(default = "default_url")] pub url: String, pub api_key: Option, + pub account: Option, + pub user: Option, pub agent_id: Option, #[serde(default = "default_timeout")] pub timeout: f64, @@ -40,6 +42,8 @@ impl Default for Config { Self { url: "http://localhost:1933".to_string(), api_key: None, + account: None, + user: None, agent_id: None, timeout: 60.0, output: "table".to_string(), @@ -108,3 +112,26 @@ pub fn get_or_create_machine_id() -> Result { Err(_) => Ok("default".to_string()), } } + +#[cfg(test)] +mod tests { + use super::Config; + + #[test] + fn config_deserializes_account_and_user_fields() { + let config: Config = serde_json::from_str( + r#"{ + "url": "http://localhost:1933", + "api_key": "test-key", + "account": "acme", + "user": "alice", + "agent_id": "assistant-1" + }"#, + ) + .expect("config should deserialize"); + + assert_eq!(config.account.as_deref(), Some("acme")); + assert_eq!(config.user.as_deref(), Some("alice")); + assert_eq!(config.agent_id.as_deref(), Some("assistant-1")); + } +} diff --git a/crates/ov_cli/src/main.rs b/crates/ov_cli/src/main.rs index 5e49d0319..240d30434 100644 --- a/crates/ov_cli/src/main.rs +++ b/crates/ov_cli/src/main.rs @@ -20,13 +20,46 @@ pub struct CliContext { } impl CliContext { - pub fn new(output_format: OutputFormat, compact: bool) -> Result { + pub fn new( + output_format: OutputFormat, + compact: bool, + account: Option, + user: Option, + agent_id: Option, + ) -> Result { let config = Config::load()?; - Ok(Self { + Ok(Self::from_config( config, output_format, compact, - }) + account, + user, + agent_id, + )) + } + + fn from_config( + mut config: Config, + output_format: OutputFormat, + compact: bool, + account: Option, + user: Option, + agent_id: Option, + ) -> Self { + if account.is_some() { + config.account = account; + } + if user.is_some() { + config.user = user; + } + if agent_id.is_some() { + config.agent_id = agent_id; + } + Self { + config, + output_format, + compact, + } } pub fn get_client(&self) -> client::HttpClient { @@ -34,6 +67,8 @@ impl CliContext { &self.config.url, self.config.api_key.clone(), self.config.agent_id.clone(), + self.config.account.clone(), + self.config.user.clone(), self.config.timeout, ) } @@ -53,6 +88,18 @@ struct Cli { #[arg(short, long, global = true, default_value = "true")] compact: bool, + /// Account identifier to send as X-OpenViking-Account + #[arg(long, global = true)] + account: Option, + + /// User identifier to send as X-OpenViking-User + #[arg(long, global = true)] + user: Option, + + /// Agent identifier to send as X-OpenViking-Agent + #[arg(long = "agent-id", global = true)] + agent_id: Option, + #[command(subcommand)] command: Commands, } @@ -202,7 +249,12 @@ enum Commands { #[arg(short, long)] all: bool, /// Maximum number of nodes to list - #[arg(long = "node-limit", short = 'n', alias = "limit", default_value = "256")] + #[arg( + long = "node-limit", + short = 'n', + alias = "limit", + default_value = "256" + )] node_limit: i32, }, /// Get directory tree @@ -216,7 +268,12 @@ enum Commands { #[arg(short, long)] all: bool, /// Maximum number of nodes to list - #[arg(long = "node-limit", short = 'n', alias = "limit", default_value = "256")] + #[arg( + long = "node-limit", + short = 'n', + alias = "limit", + default_value = "256" + )] node_limit: i32, /// Maximum depth level to traverse (default: 3) #[arg(short = 'L', long = "level-limit", default_value = "3")] @@ -290,7 +347,12 @@ enum Commands { #[arg(short, long, default_value = "")] uri: String, /// Maximum number of results - #[arg(short = 'n', long = "node-limit", alias = "limit", default_value = "10")] + #[arg( + short = 'n', + long = "node-limit", + alias = "limit", + default_value = "10" + )] node_limit: i32, /// Score threshold #[arg(short, long)] @@ -307,7 +369,12 @@ enum Commands { #[arg(long)] session_id: Option, /// Maximum number of results - #[arg(short = 'n', long = "node-limit", alias = "limit", default_value = "10")] + #[arg( + short = 'n', + long = "node-limit", + alias = "limit", + default_value = "10" + )] node_limit: i32, /// Score threshold #[arg(short, long)] @@ -324,7 +391,12 @@ enum Commands { #[arg(short, long)] ignore_case: bool, /// Maximum number of results - #[arg(short = 'n', long = "node-limit", alias = "limit", default_value = "256")] + #[arg( + short = 'n', + long = "node-limit", + alias = "limit", + default_value = "256" + )] node_limit: i32, }, /// Run file glob pattern search @@ -335,7 +407,12 @@ enum Commands { #[arg(short, long, default_value = "viking://")] uri: String, /// Maximum number of results - #[arg(short = 'n', long = "node-limit", alias = "limit", default_value = "256")] + #[arg( + short = 'n', + long = "node-limit", + alias = "limit", + default_value = "256" + )] node_limit: i32, }, /// Add memory in one shot (creates session, adds messages, commits) @@ -522,7 +599,13 @@ async fn main() { let output_format = cli.output; let compact = cli.compact; - let ctx = match CliContext::new(output_format, compact) { + let ctx = match CliContext::new( + output_format, + compact, + cli.account.clone(), + cli.user.clone(), + cli.agent_id.clone(), + ) { Ok(ctx) => ctx, Err(e) => { eprintln!("Error: {}", e); @@ -564,65 +647,71 @@ async fn main() { ) .await } - Commands::AddSkill { data, wait, timeout } => { - handle_add_skill(data, wait, timeout, ctx).await - } - Commands::Relations { uri } => { - handle_relations(uri, ctx).await - } - Commands::Link { from_uri, to_uris, reason } => { - handle_link(from_uri, to_uris, reason, ctx).await - } - Commands::Unlink { from_uri, to_uri } => { - handle_unlink(from_uri, to_uri, ctx).await - } - Commands::Export { uri, to } => { - handle_export(uri, to, ctx).await - } - Commands::Import { file_path, target_uri, force, no_vectorize } => { - handle_import(file_path, target_uri, force, no_vectorize, ctx).await - } + Commands::AddSkill { + data, + wait, + timeout, + } => handle_add_skill(data, wait, timeout, ctx).await, + Commands::Relations { uri } => handle_relations(uri, ctx).await, + Commands::Link { + from_uri, + to_uris, + reason, + } => handle_link(from_uri, to_uris, reason, ctx).await, + Commands::Unlink { from_uri, to_uri } => handle_unlink(from_uri, to_uri, ctx).await, + Commands::Export { uri, to } => handle_export(uri, to, ctx).await, + Commands::Import { + file_path, + target_uri, + force, + no_vectorize, + } => handle_import(file_path, target_uri, force, no_vectorize, ctx).await, Commands::Wait { timeout } => { let client = ctx.get_client(); commands::system::wait(&client, timeout, ctx.output_format, ctx.compact).await - }, + } Commands::Status => { let client = ctx.get_client(); commands::observer::system(&client, ctx.output_format, ctx.compact).await - }, + } Commands::Health => handle_health(ctx).await, Commands::System { action } => handle_system(action, ctx).await, Commands::Observer { action } => handle_observer(action, ctx).await, Commands::Session { action } => handle_session(action, ctx).await, Commands::Admin { action } => handle_admin(action, ctx).await, - Commands::Ls { uri, simple, recursive, abs_limit, all, node_limit } => { - handle_ls(uri, simple, recursive, abs_limit, all, node_limit, ctx).await - } - Commands::Tree { uri, abs_limit, all, node_limit, level_limit } => { - handle_tree(uri, abs_limit, all, node_limit, level_limit, ctx).await - } - Commands::Mkdir { uri } => { - handle_mkdir(uri, ctx).await - } - Commands::Rm { uri, recursive } => { - handle_rm(uri, recursive, ctx).await - } - Commands::Mv { from_uri, to_uri } => { - handle_mv(from_uri, to_uri, ctx).await - } - Commands::Stat { uri } => { - handle_stat(uri, ctx).await - } - Commands::AddMemory { content } => { - handle_add_memory(content, ctx).await - } - Commands::Tui { uri } => { - handle_tui(uri, ctx).await - } - Commands::Chat { message, session, sender, stream, no_format, no_history } => { + Commands::Ls { + uri, + simple, + recursive, + abs_limit, + all, + node_limit, + } => handle_ls(uri, simple, recursive, abs_limit, all, node_limit, ctx).await, + Commands::Tree { + uri, + abs_limit, + all, + node_limit, + level_limit, + } => handle_tree(uri, abs_limit, all, node_limit, level_limit, ctx).await, + Commands::Mkdir { uri } => handle_mkdir(uri, ctx).await, + Commands::Rm { uri, recursive } => handle_rm(uri, recursive, ctx).await, + Commands::Mv { from_uri, to_uri } => handle_mv(from_uri, to_uri, ctx).await, + Commands::Stat { uri } => handle_stat(uri, ctx).await, + Commands::AddMemory { content } => handle_add_memory(content, ctx).await, + Commands::Tui { uri } => handle_tui(uri, ctx).await, + Commands::Chat { + message, + session, + sender, + stream, + no_format, + no_history, + } => { let session_id = session.or_else(|| config::get_or_create_machine_id().ok()); let cmd = commands::chat::ChatCommand { - endpoint: std::env::var("VIKINGBOT_ENDPOINT").unwrap_or_else(|_| "http://localhost:1933/bot/v1".to_string()), + endpoint: std::env::var("VIKINGBOT_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:1933/bot/v1".to_string()), api_key: std::env::var("VIKINGBOT_API_KEY").ok(), session: session_id, sender, @@ -641,23 +730,37 @@ async fn main() { Commands::Read { uri } => handle_read(uri, ctx).await, Commands::Abstract { uri } => handle_abstract(uri, ctx).await, Commands::Overview { uri } => handle_overview(uri, ctx).await, - Commands::Reindex { uri, regenerate, wait } => { - handle_reindex(uri, regenerate, wait, ctx).await - } + Commands::Reindex { + uri, + regenerate, + wait, + } => handle_reindex(uri, regenerate, wait, ctx).await, Commands::Get { uri, local_path } => handle_get(uri, local_path, ctx).await, - Commands::Find { query, uri, node_limit, threshold } => { - handle_find(query, uri, node_limit, threshold, ctx).await - } - Commands::Search { query, uri, session_id, node_limit, threshold } => { - handle_search(query, uri, session_id, node_limit, threshold, ctx).await - } - Commands::Grep { uri, pattern, ignore_case, node_limit } => { - handle_grep(uri, pattern, ignore_case, node_limit, ctx).await - } + Commands::Find { + query, + uri, + node_limit, + threshold, + } => handle_find(query, uri, node_limit, threshold, ctx).await, + Commands::Search { + query, + uri, + session_id, + node_limit, + threshold, + } => handle_search(query, uri, session_id, node_limit, threshold, ctx).await, + Commands::Grep { + uri, + pattern, + ignore_case, + node_limit, + } => handle_grep(uri, pattern, ignore_case, node_limit, ctx).await, - Commands::Glob { pattern, uri, node_limit } => { - handle_glob(pattern, uri, node_limit, ctx).await - } + Commands::Glob { + pattern, + uri, + node_limit, + } => handle_glob(pattern, uri, node_limit, ctx).await, }; if let Err(e) = result { @@ -682,32 +785,35 @@ async fn handle_add_resource( watch_interval: f64, ctx: CliContext, ) -> Result<()> { - let is_url = path.starts_with("http://") - || path.starts_with("https://") - || path.starts_with("git@"); - + let is_url = + path.starts_with("http://") || path.starts_with("https://") || path.starts_with("git@"); + if !is_url { use std::path::Path; - + // Unescape path: replace backslash followed by space with just space let unescaped_path = path.replace("\\ ", " "); let path_obj = Path::new(&unescaped_path); if !path_obj.exists() { eprintln!("Error: Path '{}' does not exist.", path); - + // Check if there might be unquoted spaces use std::env; let args: Vec = env::args().collect(); - - if let Some(add_resource_pos) = args.iter().position(|s| s == "add-resource" || s == "add") { + + if let Some(add_resource_pos) = + args.iter().position(|s| s == "add-resource" || s == "add") + { if args.len() > add_resource_pos + 2 { let extra_args = &args[add_resource_pos + 2..]; let suggested_path = format!("{} {}", path, extra_args.join(" ")); - eprintln!("\nIt looks like you may have forgotten to quote a path with spaces."); + eprintln!( + "\nIt looks like you may have forgotten to quote a path with spaces." + ); eprintln!("Suggested command: ov add-resource \"{}\"", suggested_path); } } - + std::process::exit(1); } path = unescaped_path; @@ -731,6 +837,8 @@ async fn handle_add_resource( &ctx.config.url, ctx.config.api_key.clone(), ctx.config.agent_id.clone(), + ctx.config.account.clone(), + ctx.config.user.clone(), effective_timeout, ); commands::resources::add_resource( @@ -750,7 +858,8 @@ async fn handle_add_resource( watch_interval, ctx.output_format, ctx.compact, - ).await + ) + .await } async fn handle_add_skill( @@ -761,14 +870,19 @@ async fn handle_add_skill( ) -> Result<()> { let client = ctx.get_client(); commands::resources::add_skill( - &client, &data, wait, timeout, ctx.output_format, ctx.compact - ).await + &client, + &data, + wait, + timeout, + ctx.output_format, + ctx.compact, + ) + .await } async fn handle_relations(uri: String, ctx: CliContext) -> Result<()> { let client = ctx.get_client(); - commands::relations::list_relations(&client, &uri, ctx.output_format, ctx.compact - ).await + commands::relations::list_relations(&client, &uri, ctx.output_format, ctx.compact).await } async fn handle_link( @@ -779,25 +893,24 @@ async fn handle_link( ) -> Result<()> { let client = ctx.get_client(); commands::relations::link( - &client, &from_uri, &to_uris, &reason, ctx.output_format, ctx.compact - ).await + &client, + &from_uri, + &to_uris, + &reason, + ctx.output_format, + ctx.compact, + ) + .await } -async fn handle_unlink( - from_uri: String, - to_uri: String, - ctx: CliContext, -) -> Result<()> { +async fn handle_unlink(from_uri: String, to_uri: String, ctx: CliContext) -> Result<()> { let client = ctx.get_client(); - commands::relations::unlink( - &client, &from_uri, &to_uri, ctx.output_format, ctx.compact - ).await + commands::relations::unlink(&client, &from_uri, &to_uri, ctx.output_format, ctx.compact).await } async fn handle_export(uri: String, to: String, ctx: CliContext) -> Result<()> { let client = ctx.get_client(); - commands::pack::export(&client, &uri, &to, ctx.output_format, ctx.compact - ).await + commands::pack::export(&client, &uri, &to, ctx.output_format, ctx.compact).await } async fn handle_import( @@ -809,8 +922,15 @@ async fn handle_import( ) -> Result<()> { let client = ctx.get_client(); commands::pack::import( - &client, &file_path, &target_uri, force, no_vectorize, ctx.output_format, ctx.compact - ).await + &client, + &file_path, + &target_uri, + force, + no_vectorize, + ctx.output_format, + ctx.compact, + ) + .await } async fn handle_system(cmd: SystemCommands, ctx: CliContext) -> Result<()> { @@ -823,8 +943,7 @@ async fn handle_system(cmd: SystemCommands, ctx: CliContext) -> Result<()> { commands::system::status(&client, ctx.output_format, ctx.compact).await } SystemCommands::Health => { - let _ = - commands::system::health(&client, ctx.output_format, ctx.compact).await?; + let _ = commands::system::health(&client, ctx.output_format, ctx.compact).await?; Ok(()) } SystemCommands::Crypto { action } => commands::crypto::handle_crypto(action).await, @@ -865,21 +984,31 @@ async fn handle_session(cmd: SessionCommands, ctx: CliContext) -> Result<()> { commands::session::list_sessions(&client, ctx.output_format, ctx.compact).await } SessionCommands::Get { session_id } => { - commands::session::get_session(&client, &session_id, ctx.output_format, ctx.compact - ).await + commands::session::get_session(&client, &session_id, ctx.output_format, ctx.compact) + .await } SessionCommands::Delete { session_id } => { - commands::session::delete_session(&client, &session_id, ctx.output_format, ctx.compact - ).await + commands::session::delete_session(&client, &session_id, ctx.output_format, ctx.compact) + .await } - SessionCommands::AddMessage { session_id, role, content } => { + SessionCommands::AddMessage { + session_id, + role, + content, + } => { commands::session::add_message( - &client, &session_id, &role, &content, ctx.output_format, ctx.compact - ).await + &client, + &session_id, + &role, + &content, + ctx.output_format, + ctx.compact, + ) + .await } SessionCommands::Commit { session_id } => { - commands::session::commit_session(&client, &session_id, ctx.output_format, ctx.compact - ).await + commands::session::commit_session(&client, &session_id, ctx.output_format, ctx.compact) + .await } } } @@ -887,43 +1016,84 @@ async fn handle_session(cmd: SessionCommands, ctx: CliContext) -> Result<()> { async fn handle_admin(cmd: AdminCommands, ctx: CliContext) -> Result<()> { let client = ctx.get_client(); match cmd { - AdminCommands::CreateAccount { account_id, admin_user_id } => { + AdminCommands::CreateAccount { + account_id, + admin_user_id, + } => { commands::admin::create_account( - &client, &account_id, &admin_user_id, ctx.output_format, ctx.compact, - ).await + &client, + &account_id, + &admin_user_id, + ctx.output_format, + ctx.compact, + ) + .await } AdminCommands::ListAccounts => { commands::admin::list_accounts(&client, ctx.output_format, ctx.compact).await } AdminCommands::DeleteAccount { account_id } => { - commands::admin::delete_account( - &client, &account_id, ctx.output_format, ctx.compact, - ).await + commands::admin::delete_account(&client, &account_id, ctx.output_format, ctx.compact) + .await } - AdminCommands::RegisterUser { account_id, user_id, role } => { + AdminCommands::RegisterUser { + account_id, + user_id, + role, + } => { commands::admin::register_user( - &client, &account_id, &user_id, &role, ctx.output_format, ctx.compact, - ).await + &client, + &account_id, + &user_id, + &role, + ctx.output_format, + ctx.compact, + ) + .await } AdminCommands::ListUsers { account_id } => { - commands::admin::list_users( - &client, &account_id, ctx.output_format, ctx.compact, - ).await + commands::admin::list_users(&client, &account_id, ctx.output_format, ctx.compact).await } - AdminCommands::RemoveUser { account_id, user_id } => { + AdminCommands::RemoveUser { + account_id, + user_id, + } => { commands::admin::remove_user( - &client, &account_id, &user_id, ctx.output_format, ctx.compact, - ).await + &client, + &account_id, + &user_id, + ctx.output_format, + ctx.compact, + ) + .await } - AdminCommands::SetRole { account_id, user_id, role } => { + AdminCommands::SetRole { + account_id, + user_id, + role, + } => { commands::admin::set_role( - &client, &account_id, &user_id, &role, ctx.output_format, ctx.compact, - ).await + &client, + &account_id, + &user_id, + &role, + ctx.output_format, + ctx.compact, + ) + .await } - AdminCommands::RegenerateKey { account_id, user_id } => { + AdminCommands::RegenerateKey { + account_id, + user_id, + } => { commands::admin::regenerate_key( - &client, &account_id, &user_id, ctx.output_format, ctx.compact, - ).await + &client, + &account_id, + &user_id, + ctx.output_format, + ctx.compact, + ) + .await } } } @@ -940,21 +1110,17 @@ async fn handle_config(cmd: ConfigCommands, _ctx: CliContext) -> Result<()> { output::output_success( &serde_json::to_value(config).unwrap(), output::OutputFormat::Json, - true + true, ); Ok(()) } - ConfigCommands::Validate => { - match Config::load() { - Ok(_) => { - println!("Configuration is valid"); - Ok(()) - } - Err(e) => { - Err(Error::Config(e.to_string())) - } + ConfigCommands::Validate => match Config::load() { + Ok(_) => { + println!("Configuration is valid"); + Ok(()) } - } + Err(e) => Err(Error::Config(e.to_string())), + }, } } @@ -975,7 +1141,15 @@ async fn handle_overview(uri: String, ctx: CliContext) -> Result<()> { async fn handle_reindex(uri: String, regenerate: bool, wait: bool, ctx: CliContext) -> Result<()> { let client = ctx.get_client(); - commands::content::reindex(&client, &uri, regenerate, wait, ctx.output_format, ctx.compact).await + commands::content::reindex( + &client, + &uri, + regenerate, + wait, + ctx.output_format, + ctx.compact, + ) + .await } async fn handle_get(uri: String, local_path: String, ctx: CliContext) -> Result<()> { @@ -997,7 +1171,16 @@ async fn handle_find( params.push(format!("\"{}\"", query)); print_command_echo("ov find", ¶ms.join(" "), ctx.config.echo_command); let client = ctx.get_client(); - commands::search::find(&client, &query, &uri, node_limit, threshold, ctx.output_format, ctx.compact).await + commands::search::find( + &client, + &query, + &uri, + node_limit, + threshold, + ctx.output_format, + ctx.compact, + ) + .await } async fn handle_search( @@ -1018,7 +1201,17 @@ async fn handle_search( params.push(format!("\"{}\"", query)); print_command_echo("ov search", ¶ms.join(" "), ctx.config.echo_command); let client = ctx.get_client(); - commands::search::search(&client, &query, &uri, session_id, node_limit, threshold, ctx.output_format, ctx.compact).await + commands::search::search( + &client, + &query, + &uri, + session_id, + node_limit, + threshold, + ctx.output_format, + ctx.compact, + ) + .await } /// Print command with specified parameters for debugging @@ -1028,35 +1221,81 @@ fn print_command_echo(command: &str, params: &str, echo_enabled: bool) { } } -async fn handle_ls(uri: String, simple: bool, recursive: bool, abs_limit: i32, show_all_hidden: bool, node_limit: i32, ctx: CliContext) -> Result<()> { +async fn handle_ls( + uri: String, + simple: bool, + recursive: bool, + abs_limit: i32, + show_all_hidden: bool, + node_limit: i32, + ctx: CliContext, +) -> Result<()> { let mut params = vec![ uri.clone(), format!("-l {}", abs_limit), format!("-n {}", node_limit), ]; - if simple { params.push("-s".to_string()); } - if recursive { params.push("-r".to_string()); } - if show_all_hidden { params.push("-a".to_string()); } + if simple { + params.push("-s".to_string()); + } + if recursive { + params.push("-r".to_string()); + } + if show_all_hidden { + params.push("-a".to_string()); + } print_command_echo("ov ls", ¶ms.join(" "), ctx.config.echo_command); let client = ctx.get_client(); let api_output = if ctx.compact { "agent" } else { "original" }; - commands::filesystem::ls(&client, &uri, simple, recursive, api_output, abs_limit, show_all_hidden, node_limit, ctx.output_format, ctx.compact).await + commands::filesystem::ls( + &client, + &uri, + simple, + recursive, + api_output, + abs_limit, + show_all_hidden, + node_limit, + ctx.output_format, + ctx.compact, + ) + .await } -async fn handle_tree(uri: String, abs_limit: i32, show_all_hidden: bool, node_limit: i32, level_limit: i32, ctx: CliContext) -> Result<()> { +async fn handle_tree( + uri: String, + abs_limit: i32, + show_all_hidden: bool, + node_limit: i32, + level_limit: i32, + ctx: CliContext, +) -> Result<()> { let mut params = vec![ uri.clone(), format!("-l {}", abs_limit), format!("-n {}", node_limit), format!("-L {}", level_limit), ]; - if show_all_hidden { params.push("-a".to_string()); } + if show_all_hidden { + params.push("-a".to_string()); + } print_command_echo("ov tree", ¶ms.join(" "), ctx.config.echo_command); let client = ctx.get_client(); let api_output = if ctx.compact { "agent" } else { "original" }; - commands::filesystem::tree(&client, &uri, api_output, abs_limit, show_all_hidden, node_limit, level_limit, ctx.output_format, ctx.compact).await + commands::filesystem::tree( + &client, + &uri, + api_output, + abs_limit, + show_all_hidden, + node_limit, + level_limit, + ctx.output_format, + ctx.compact, + ) + .await } async fn handle_mkdir(uri: String, ctx: CliContext) -> Result<()> { @@ -1079,29 +1318,57 @@ async fn handle_stat(uri: String, ctx: CliContext) -> Result<()> { commands::filesystem::stat(&client, &uri, ctx.output_format, ctx.compact).await } -async fn handle_grep(uri: String, pattern: String, ignore_case: bool, node_limit: i32, ctx: CliContext) -> Result<()> { +async fn handle_grep( + uri: String, + pattern: String, + ignore_case: bool, + node_limit: i32, + ctx: CliContext, +) -> Result<()> { let mut params = vec![format!("--uri={}", uri), format!("-n {}", node_limit)]; - if ignore_case { params.push("-i".to_string()); } + if ignore_case { + params.push("-i".to_string()); + } params.push(format!("\"{}\"", pattern)); print_command_echo("ov grep", ¶ms.join(" "), ctx.config.echo_command); let client = ctx.get_client(); - commands::search::grep(&client, &uri, &pattern, ignore_case, node_limit, ctx.output_format, ctx.compact).await + commands::search::grep( + &client, + &uri, + &pattern, + ignore_case, + node_limit, + ctx.output_format, + ctx.compact, + ) + .await } - async fn handle_glob(pattern: String, uri: String, node_limit: i32, ctx: CliContext) -> Result<()> { - let params = vec![format!("--uri={}", uri), format!("-n {}", node_limit), format!("\"{}\"", pattern)]; + let params = vec![ + format!("--uri={}", uri), + format!("-n {}", node_limit), + format!("\"{}\"", pattern), + ]; print_command_echo("ov glob", ¶ms.join(" "), ctx.config.echo_command); let client = ctx.get_client(); - commands::search::glob(&client, &pattern, &uri, node_limit, ctx.output_format, ctx.compact).await + commands::search::glob( + &client, + &pattern, + &uri, + node_limit, + ctx.output_format, + ctx.compact, + ) + .await } async fn handle_health(ctx: CliContext) -> Result<()> { let client = ctx.get_client(); - + // Reuse the system health command let _ = commands::system::health(&client, ctx.output_format, ctx.compact).await?; - + Ok(()) } @@ -1109,3 +1376,57 @@ async fn handle_tui(uri: String, ctx: CliContext) -> Result<()> { let client = ctx.get_client(); tui::run_tui(client, &uri).await } + +#[cfg(test)] +mod tests { + use super::{Cli, CliContext}; + use crate::config::Config; + use crate::output::OutputFormat; + use clap::Parser; + + #[test] + fn cli_parses_global_identity_override_flags() { + let cli = Cli::try_parse_from([ + "ov", + "--account", + "acme", + "--user", + "alice", + "--agent-id", + "assistant-1", + "ls", + ]) + .expect("cli should parse"); + + assert_eq!(cli.account.as_deref(), Some("acme")); + assert_eq!(cli.user.as_deref(), Some("alice")); + assert_eq!(cli.agent_id.as_deref(), Some("assistant-1")); + } + + #[test] + fn cli_context_overrides_identity_from_cli_flags() { + let config = Config { + url: "http://localhost:1933".to_string(), + api_key: Some("test-key".to_string()), + account: Some("from-config-account".to_string()), + user: Some("from-config-user".to_string()), + agent_id: Some("from-config-agent".to_string()), + timeout: 60.0, + output: "table".to_string(), + echo_command: true, + }; + + let ctx = CliContext::from_config( + config, + OutputFormat::Json, + true, + Some("from-cli-account".to_string()), + Some("from-cli-user".to_string()), + Some("from-cli-agent".to_string()), + ); + + assert_eq!(ctx.config.account.as_deref(), Some("from-cli-account")); + assert_eq!(ctx.config.user.as_deref(), Some("from-cli-user")); + assert_eq!(ctx.config.agent_id.as_deref(), Some("from-cli-agent")); + } +} diff --git a/docs/en/api/01-overview.md b/docs/en/api/01-overview.md index ae4ddbd44..f9bf74f87 100644 --- a/docs/en/api/01-overview.md +++ b/docs/en/api/01-overview.md @@ -73,6 +73,8 @@ export OPENVIKING_CLI_CONFIG_FILE=/path/to/ovcli.conf { "url": "http://localhost:1933", "api_key": "your-key", + "account": "acme", + "user": "alice", "agent_id": "my-agent" } ``` @@ -81,6 +83,9 @@ export OPENVIKING_CLI_CONFIG_FILE=/path/to/ovcli.conf |-------|-------------|---------| | `url` | Server address | (required) | | `api_key` | API key | `null` (no auth) | +| `account` | Default account header for tenant-scoped requests | `null` | +| `user` | Default user header for tenant-scoped requests | `null` | +| `agent_id` | Agent identifier header | `null` | | `timeout` | HTTP request timeout in seconds | `60.0` | | `output` | Default output format: `"table"` or `"json"` | `"table"` | diff --git a/docs/en/guides/01-configuration.md b/docs/en/guides/01-configuration.md index bd6fe9d55..47dc551e5 100644 --- a/docs/en/guides/01-configuration.md +++ b/docs/en/guides/01-configuration.md @@ -766,6 +766,8 @@ Config file for the HTTP client (`SyncHTTPClient` / `AsyncHTTPClient`) and CLI t { "url": "http://localhost:1933", "api_key": "your-secret-key", + "account": "acme", + "user": "alice", "agent_id": "my-agent", "output": "table" } @@ -775,9 +777,17 @@ Config file for the HTTP client (`SyncHTTPClient` / `AsyncHTTPClient`) and CLI t |-------|-------------|---------| | `url` | Server address | (required) | | `api_key` | API key for authentication (root key or user key) | `null` (no auth) | +| `account` | Default account sent as `X-OpenViking-Account` | `null` | +| `user` | Default user sent as `X-OpenViking-User` | `null` | | `agent_id` | Agent identifier for agent space isolation | `null` | | `output` | Default output format: `"table"` or `"json"` | `"table"` | +CLI flags can override these identity fields per command: + +```bash +openviking --account acme --user alice --agent-id assistant-2 ls viking:// +``` + See [Deployment](./03-deployment.md) for details. ## server Section @@ -789,6 +799,7 @@ When running OpenViking as an HTTP service, add a `server` section to `ov.conf`: "server": { "host": "0.0.0.0", "port": 1933, + "auth_mode": "api_key", "root_api_key": "your-secret-root-key", "cors_origins": ["*"] } @@ -799,10 +810,13 @@ When running OpenViking as an HTTP service, add a `server` section to `ov.conf`: |-------|------|-------------|---------| | `host` | str | Bind address | `0.0.0.0` | | `port` | int | Bind port | `1933` | -| `root_api_key` | str | Root API key for multi-tenant auth, disabled if not set | `null` | +| `auth_mode` | str | Authentication mode: `"api_key"` or `"trusted"` | `"api_key"` | +| `root_api_key` | str | Root API key for multi-tenant auth in `api_key` mode | `null` | | `cors_origins` | list | Allowed CORS origins | `["*"]` | -When `root_api_key` is configured, the server enables multi-tenant authentication. Use the Admin API to create accounts and user keys. When not set, the server runs in dev mode with no authentication. +`api_key` mode uses API keys. `trusted` mode trusts `X-OpenViking-Account` / `X-OpenViking-User` headers from a trusted gateway or internal caller. + +When `root_api_key` is configured, the server enables multi-tenant authentication. Use the Admin API to create accounts and user keys. Development mode only applies when `auth_mode = "api_key"` and `root_api_key` is not set. For startup and deployment details see [Deployment](./03-deployment.md), for authentication see [Authentication](./04-authentication.md). diff --git a/docs/en/guides/04-authentication.md b/docs/en/guides/04-authentication.md index 950c35990..f7f68be20 100644 --- a/docs/en/guides/04-authentication.md +++ b/docs/en/guides/04-authentication.md @@ -1,6 +1,6 @@ # Authentication -OpenViking Server supports multi-tenant API key authentication with role-based access control. +OpenViking Server supports two authentication modes with role-based access control: `api_key` and `trusted`. ## Overview @@ -13,13 +13,21 @@ OpenViking uses a two-layer API key system: All API keys are plain random tokens with no embedded identity. The server resolves identity by first comparing against the root key, then looking up the user key index. +## Authentication Modes + +| Mode | `server.auth_mode` | Identity Source | Typical Use | +|------|--------------------|-----------------|-------------| +| API key mode | `"api_key"` | API key, with optional tenant headers for root requests | Standard multi-tenant deployment | +| Trusted mode | `"trusted"` | `X-OpenViking-Account` / `X-OpenViking-User` / optional `X-OpenViking-Agent` headers | Behind a trusted gateway or internal network boundary | + ## Setting Up (Server Side) -Configure the root API key in the `server` section of `ov.conf`: +Configure the authentication mode in the `server` section of `ov.conf`: ```json { "server": { + "auth_mode": "api_key", "root_api_key": "your-secret-root-key" } } @@ -87,10 +95,20 @@ client = ov.SyncHTTPClient( { "url": "http://localhost:1933", "api_key": "", + "account": "acme", + "user": "alice", "agent_id": "my-agent" } ``` +When you use a regular user key, `account` and `user` are optional because the server can derive them from the key. They are recommended when you use `trusted` mode or a root key against tenant-scoped APIs. + +**CLI override flags** + +```bash +openviking --account acme --user alice --agent-id my-agent ls viking:// +``` + ### Accessing Tenant Data with Root Key When using the root key to access tenant-scoped data APIs (e.g. `ls`, `find`, `sessions`), you must specify the target account and user. The server will reject the request otherwise. Admin API and system status endpoints are not affected. @@ -124,10 +142,53 @@ client = ov.SyncHTTPClient( "url": "http://localhost:1933", "api_key": "your-secret-root-key", "account": "acme", - "user": "alice" + "user": "alice", + "agent_id": "my-agent" +} +``` + +## Trusted Mode + +Trusted mode skips user-key lookup and instead trusts explicit identity headers on each request: + +```json +{ + "server": { + "auth_mode": "trusted", + "host": "127.0.0.1" + } } ``` +Rules in trusted mode: + +- `X-OpenViking-Account` and `X-OpenViking-User` are required on tenant-scoped requests. +- `X-OpenViking-Agent` is optional and defaults to `default`. +- If `root_api_key` is also configured, every request must still provide a matching API key. +- Only expose this mode behind a trusted network boundary or an identity-injecting gateway. + +**curl** + +```bash +curl http://localhost:1933/api/v1/fs/ls?uri=viking:// \ + -H "X-OpenViking-Account: acme" \ + -H "X-OpenViking-User: alice" \ + -H "X-OpenViking-Agent: my-agent" +``` + +**Python SDK** + +```python +import openviking as ov + +client = ov.SyncHTTPClient( + url="http://localhost:1933", + account="acme", + user="alice", + agent_id="my-agent", +) +``` + ## Roles and Permissions | Role | Scope | Capabilities | @@ -138,7 +199,7 @@ client = ov.SyncHTTPClient( ## Development Mode -When no `root_api_key` is configured, authentication is disabled. All requests are accepted as ROOT with the default account. **This is only allowed when the server binds to localhost** (`127.0.0.1`, `localhost`, or `::1`). If `host` is set to a non-loopback address (e.g. `0.0.0.0`) without a `root_api_key`, the server will refuse to start. +When `auth_mode = "api_key"` and no `root_api_key` is configured, authentication is disabled. All requests are accepted as ROOT with the default account. **This is only allowed when the server binds to localhost** (`127.0.0.1`, `localhost`, or `::1`). If `host` is set to a non-loopback address (e.g. `0.0.0.0`) without a `root_api_key`, the server will refuse to start. ```json { diff --git a/docs/zh/api/01-overview.md b/docs/zh/api/01-overview.md index 3b1346098..48675085a 100644 --- a/docs/zh/api/01-overview.md +++ b/docs/zh/api/01-overview.md @@ -73,6 +73,8 @@ export OPENVIKING_CLI_CONFIG_FILE=/path/to/ovcli.conf { "url": "http://localhost:1933", "api_key": "your-key", + "account": "acme", + "user": "alice", "agent_id": "my-agent" } ``` @@ -81,6 +83,8 @@ export OPENVIKING_CLI_CONFIG_FILE=/path/to/ovcli.conf |------|------|--------| | `url` | 服务端地址 | (必填) | | `api_key` | API Key | `null`(无认证) | +| `account` | 面向租户请求的默认 account 请求头 | `null` | +| `user` | 面向租户请求的默认 user 请求头 | `null` | | `agent_id` | Agent 标识符 | `null` | | `timeout` | HTTP 请求超时时间(秒) | `60.0` | | `output` | 默认输出格式:`"table"` 或 `"json"` | `"table"` | diff --git a/docs/zh/guides/01-configuration.md b/docs/zh/guides/01-configuration.md index 675c34234..e357e42f6 100644 --- a/docs/zh/guides/01-configuration.md +++ b/docs/zh/guides/01-configuration.md @@ -743,6 +743,8 @@ HTTP 客户端(`SyncHTTPClient` / `AsyncHTTPClient`)和 CLI 工具连接远 { "url": "http://localhost:1933", "api_key": "your-secret-key", + "account": "acme", + "user": "alice", "agent_id": "my-agent", "output": "table" } @@ -752,9 +754,17 @@ HTTP 客户端(`SyncHTTPClient` / `AsyncHTTPClient`)和 CLI 工具连接远 |------|------|--------| | `url` | 服务端地址 | (必填) | | `api_key` | API Key 认证(root key 或 user key) | `null`(无认证) | +| `account` | 默认发送为 `X-OpenViking-Account` 的租户标识 | `null` | +| `user` | 默认发送为 `X-OpenViking-User` 的用户标识 | `null` | | `agent_id` | Agent 标识,用于 agent space 隔离 | `null` | | `output` | 默认输出格式:`"table"` 或 `"json"` | `"table"` | +也可以在单次命令里用 CLI 参数覆盖这些身份字段: + +```bash +openviking --account acme --user alice --agent-id assistant-2 ls viking:// +``` + 详见 [服务部署](./03-deployment.md)。 ## server 段 @@ -766,6 +776,7 @@ HTTP 客户端(`SyncHTTPClient` / `AsyncHTTPClient`)和 CLI 工具连接远 "server": { "host": "0.0.0.0", "port": 1933, + "auth_mode": "api_key", "root_api_key": "your-secret-root-key", "cors_origins": ["*"] } @@ -776,10 +787,13 @@ HTTP 客户端(`SyncHTTPClient` / `AsyncHTTPClient`)和 CLI 工具连接远 |------|------|------|--------| | `host` | str | 绑定地址 | `0.0.0.0` | | `port` | int | 绑定端口 | `1933` | -| `root_api_key` | str | Root API Key,启用多租户认证,不设则为开发模式 | `null` | +| `auth_mode` | str | 认证模式:`"api_key"` 或 `"trusted"` | `"api_key"` | +| `root_api_key` | str | Root API Key。在 `api_key` 模式下启用多租户认证 | `null` | | `cors_origins` | list | CORS 允许的来源 | `["*"]` | -配置 `root_api_key` 后,服务端启用多租户认证。通过 Admin API 创建工作区和用户 key。不配置时为开发模式,不需要认证。 +`api_key` 模式使用 API Key 认证;`trusted` 模式信任上游网关或受信调用方注入的 `X-OpenViking-Account` / `X-OpenViking-User` 请求头。 + +配置 `root_api_key` 后,服务端启用多租户认证。通过 Admin API 创建工作区和用户 key。只有在 `auth_mode = "api_key"` 且未配置 `root_api_key` 时,服务端才会进入开发模式。 启动方式和部署详情见 [服务部署](./03-deployment.md),认证详情见 [认证](./04-authentication.md)。 diff --git a/docs/zh/guides/04-authentication.md b/docs/zh/guides/04-authentication.md index cf937104d..d1d5d7159 100644 --- a/docs/zh/guides/04-authentication.md +++ b/docs/zh/guides/04-authentication.md @@ -1,6 +1,6 @@ # 认证 -OpenViking Server 支持多租户 API Key 认证和基于角色的访问控制。 +OpenViking Server 支持两种认证模式,并带有基于角色的访问控制:`api_key` 和 `trusted`。 ## 概述 @@ -13,13 +13,21 @@ OpenViking 使用两层 API Key 体系: 所有 API Key 均为纯随机 token,不携带身份信息。服务端通过先比对 root key、再查 user key 索引的方式确定身份。 +## 认证模式 + +| 模式 | `server.auth_mode` | 身份来源 | 典型使用场景 | +|------|--------------------|----------|--------------| +| API Key 模式 | `"api_key"` | API Key,root 请求可附带租户请求头 | 标准多租户部署 | +| Trusted 模式 | `"trusted"` | `X-OpenViking-Account` / `X-OpenViking-User` / 可选 `X-OpenViking-Agent` 请求头 | 部署在受信网关或内网边界之后 | + ## 服务端配置 -在 `ov.conf` 的 `server` 段配置 root API key: +在 `ov.conf` 的 `server` 段配置认证模式: ```json { "server": { + "auth_mode": "api_key", "root_api_key": "your-secret-root-key" } } @@ -87,10 +95,20 @@ client = ov.SyncHTTPClient( { "url": "http://localhost:1933", "api_key": "", + "account": "acme", + "user": "alice", "agent_id": "my-agent" } ``` +如果使用普通 `user key`,`account` 和 `user` 可以省略,因为服务端可以从 key 反查出来;如果使用 `trusted` 模式,或者用 `root key` 访问租户级 API,则建议明确配置。 + +**CLI 覆盖参数** + +```bash +openviking --account acme --user alice --agent-id my-agent ls viking:// +``` + ### 使用 Root Key 访问租户数据 使用 root key 访问租户级数据 API(如 `ls`、`find`、`sessions` 等)时,必须指定目标 account 和 user,否则服务端将拒绝请求。Admin API 和系统状态端点不受此限制。 @@ -124,10 +142,53 @@ client = ov.SyncHTTPClient( "url": "http://localhost:1933", "api_key": "your-secret-root-key", "account": "acme", - "user": "alice" + "user": "alice", + "agent_id": "my-agent" +} +``` + +## Trusted 模式 + +Trusted 模式不会查 user key,而是直接信任每个请求显式携带的身份请求头: + +```json +{ + "server": { + "auth_mode": "trusted", + "host": "127.0.0.1" + } } ``` +Trusted 模式规则: + +- 租户级请求必须包含 `X-OpenViking-Account` 和 `X-OpenViking-User` +- `X-OpenViking-Agent` 可选,缺省为 `default` +- 如果同时配置了 `root_api_key`,每个请求仍然必须带匹配的 API Key +- 只应部署在受信网络边界之后,或由身份注入网关统一转发 + +**curl** + +```bash +curl http://localhost:1933/api/v1/fs/ls?uri=viking:// \ + -H "X-OpenViking-Account: acme" \ + -H "X-OpenViking-User: alice" \ + -H "X-OpenViking-Agent: my-agent" +``` + +**Python SDK** + +```python +import openviking as ov + +client = ov.SyncHTTPClient( + url="http://localhost:1933", + account="acme", + user="alice", + agent_id="my-agent", +) +``` + ## 角色与权限 | 角色 | 作用域 | 能力 | @@ -138,7 +199,7 @@ client = ov.SyncHTTPClient( ## 开发模式 -不配置 `root_api_key` 时,认证禁用,所有请求以 ROOT 身份访问 default account。**此模式仅允许在服务器绑定 localhost 时使用**(`127.0.0.1`、`localhost` 或 `::1`)。如果 `host` 设置为非回环地址(如 `0.0.0.0`)且未配置 `root_api_key`,服务器将拒绝启动。 +当 `auth_mode = "api_key"` 且未配置 `root_api_key` 时,认证禁用,所有请求以 ROOT 身份访问 default account。**此模式仅允许在服务器绑定 localhost 时使用**(`127.0.0.1`、`localhost` 或 `::1`)。如果 `host` 设置为非回环地址(如 `0.0.0.0`)且未配置 `root_api_key`,服务器将拒绝启动。 ```json { diff --git a/examples/ovcli.conf.example b/examples/ovcli.conf.example index c0a6227ba..e9d8c0b37 100644 --- a/examples/ovcli.conf.example +++ b/examples/ovcli.conf.example @@ -1,6 +1,8 @@ { "url": "http://localhost:1933", "api_key": null, + "account": null, + "user": null, "agent_id": null, "timeout": 60.0, "output": "table",