Skip to content

Commit 2ad4236

Browse files
committed
feat(cli): support tenant identity defaults and overrides
1 parent 7b2b910 commit 2ad4236

File tree

11 files changed

+820
-212
lines changed

11 files changed

+820
-212
lines changed

crates/ov_cli/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ Create `~/.openviking/ovcli.conf`:
2525
```json
2626
{
2727
"url": "http://localhost:1933",
28-
"api_key": "your-api-key"
28+
"api_key": "your-api-key",
29+
"account": "acme",
30+
"user": "alice",
31+
"agent_id": "assistant-1"
2932
}
3033
```
3134

35+
`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.
36+
3237
## Quick Start
3338

3439
```bash
@@ -126,6 +131,9 @@ ov find "API authentication" --threshold 0.7 --limit 5
126131
# Recursive list
127132
ov ls viking://resources --recursive
128133

134+
# Temporarily override identity from CLI flags
135+
ov --account acme --user alice --agent-id assistant-2 ls viking://
136+
129137
# Glob search
130138
ov glob "**/*.md" --uri viking://resources
131139

crates/ov_cli/src/client.rs

Lines changed: 128 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use serde_json::Value;
44
use std::fs::File;
55
use std::path::Path;
66
use tempfile::NamedTempFile;
7-
use zip::write::FileOptions;
87
use zip::CompressionMethod;
8+
use zip::write::FileOptions;
99

1010
use crate::error::{Error, Result};
1111

@@ -15,6 +15,8 @@ pub struct HttpClient {
1515
http: ReqwestClient,
1616
base_url: String,
1717
api_key: Option<String>,
18+
account: Option<String>,
19+
user: Option<String>,
1820
agent_id: Option<String>,
1921
}
2022

@@ -24,6 +26,8 @@ impl HttpClient {
2426
base_url: impl Into<String>,
2527
api_key: Option<String>,
2628
agent_id: Option<String>,
29+
account: Option<String>,
30+
user: Option<String>,
2731
timeout_secs: f64,
2832
) -> Self {
2933
let http = ReqwestClient::builder()
@@ -35,6 +39,8 @@ impl HttpClient {
3539
http,
3640
base_url: base_url.into().trim_end_matches('/').to_string(),
3741
api_key,
42+
account,
43+
user,
3844
agent_id,
3945
}
4046
}
@@ -51,7 +57,8 @@ impl HttpClient {
5157
let temp_file = NamedTempFile::new()?;
5258
let file = File::create(temp_file.path())?;
5359
let mut zip = zip::ZipWriter::new(file);
54-
let options: FileOptions<'_, ()> = FileOptions::default().compression_method(CompressionMethod::Deflated);
60+
let options: FileOptions<'_, ()> =
61+
FileOptions::default().compression_method(CompressionMethod::Deflated);
5562

5663
let walkdir = walkdir::WalkDir::new(dir_path);
5764
for entry in walkdir.into_iter().filter_map(|e| e.ok()) {
@@ -78,14 +85,13 @@ impl HttpClient {
7885

7986
// Read file content
8087
let file_content = tokio::fs::read(file_path).await?;
81-
88+
8289
// Create multipart form
83-
let part = reqwest::multipart::Part::bytes(file_content)
84-
.file_name(file_name.to_string());
85-
86-
let part = part.mime_str("application/octet-stream").map_err(|e| {
87-
Error::Network(format!("Failed to set mime type: {}", e))
88-
})?;
90+
let part = reqwest::multipart::Part::bytes(file_content).file_name(file_name.to_string());
91+
92+
let part = part
93+
.mime_str("application/octet-stream")
94+
.map_err(|e| Error::Network(format!("Failed to set mime type: {}", e)))?;
8995

9096
let form = reqwest::multipart::Form::new().part("file", part);
9197

@@ -126,6 +132,16 @@ impl HttpClient {
126132
headers.insert("X-OpenViking-Agent", value);
127133
}
128134
}
135+
if let Some(account) = &self.account {
136+
if let Ok(value) = reqwest::header::HeaderValue::from_str(account) {
137+
headers.insert("X-OpenViking-Account", value);
138+
}
139+
}
140+
if let Some(user) = &self.user {
141+
if let Ok(value) = reqwest::header::HeaderValue::from_str(user) {
142+
headers.insert("X-OpenViking-User", value);
143+
}
144+
}
129145
headers
130146
}
131147

@@ -224,10 +240,7 @@ impl HttpClient {
224240
self.handle_response(response).await
225241
}
226242

227-
async fn handle_response<T: DeserializeOwned>(
228-
&self,
229-
response: reqwest::Response,
230-
) -> Result<T> {
243+
async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
231244
let status = response.status();
232245

233246
// Handle empty response (204 No Content, etc.)
@@ -248,7 +261,11 @@ impl HttpClient {
248261
.and_then(|e| e.get("message"))
249262
.and_then(|m| m.as_str())
250263
.map(|s| s.to_string())
251-
.or_else(|| json.get("detail").and_then(|d| d.as_str()).map(|s| s.to_string()))
264+
.or_else(|| {
265+
json.get("detail")
266+
.and_then(|d| d.as_str())
267+
.map(|s| s.to_string())
268+
})
252269
.unwrap_or_else(|| format!("HTTP error {}", status));
253270
return Err(Error::Api(error_msg));
254271
}
@@ -296,7 +313,12 @@ impl HttpClient {
296313
self.get("/api/v1/content/overview", &params).await
297314
}
298315

299-
pub async fn reindex(&self, uri: &str, regenerate: bool, wait: bool) -> Result<serde_json::Value> {
316+
pub async fn reindex(
317+
&self,
318+
uri: &str,
319+
regenerate: bool,
320+
wait: bool,
321+
) -> Result<serde_json::Value> {
300322
let body = serde_json::json!({
301323
"uri": uri,
302324
"regenerate": regenerate,
@@ -309,7 +331,7 @@ impl HttpClient {
309331
pub async fn get_bytes(&self, uri: &str) -> Result<Vec<u8>> {
310332
let url = format!("{}/api/v1/content/download", self.base_url);
311333
let params = vec![("uri".to_string(), uri.to_string())];
312-
334+
313335
let response = self
314336
.http
315337
.get(&url)
@@ -326,20 +348,22 @@ impl HttpClient {
326348
.json()
327349
.await
328350
.map_err(|e| Error::Network(format!("Failed to parse error response: {}", e)));
329-
351+
330352
let error_msg = match json_result {
331-
Ok(json) => {
332-
json
333-
.get("error")
334-
.and_then(|e| e.get("message"))
335-
.and_then(|m| m.as_str())
336-
.map(|s| s.to_string())
337-
.or_else(|| json.get("detail").and_then(|d| d.as_str()).map(|s| s.to_string()))
338-
.unwrap_or_else(|| format!("HTTP error {}", status))
339-
}
353+
Ok(json) => json
354+
.get("error")
355+
.and_then(|e| e.get("message"))
356+
.and_then(|m| m.as_str())
357+
.map(|s| s.to_string())
358+
.or_else(|| {
359+
json.get("detail")
360+
.and_then(|d| d.as_str())
361+
.map(|s| s.to_string())
362+
})
363+
.unwrap_or_else(|| format!("HTTP error {}", status)),
340364
Err(_) => format!("HTTP error {}", status),
341365
};
342-
366+
343367
return Err(Error::Api(error_msg));
344368
}
345369

@@ -352,7 +376,16 @@ impl HttpClient {
352376

353377
// ============ Filesystem Methods ============
354378

355-
pub async fn ls(&self, uri: &str, simple: bool, recursive: bool, output: &str, abs_limit: i32, show_all_hidden: bool, node_limit: i32) -> Result<serde_json::Value> {
379+
pub async fn ls(
380+
&self,
381+
uri: &str,
382+
simple: bool,
383+
recursive: bool,
384+
output: &str,
385+
abs_limit: i32,
386+
show_all_hidden: bool,
387+
node_limit: i32,
388+
) -> Result<serde_json::Value> {
356389
let params = vec![
357390
("uri".to_string(), uri.to_string()),
358391
("simple".to_string(), simple.to_string()),
@@ -365,7 +398,15 @@ impl HttpClient {
365398
self.get("/api/v1/fs/ls", &params).await
366399
}
367400

368-
pub async fn tree(&self, uri: &str, output: &str, abs_limit: i32, show_all_hidden: bool, node_limit: i32, level_limit: i32) -> Result<serde_json::Value> {
401+
pub async fn tree(
402+
&self,
403+
uri: &str,
404+
output: &str,
405+
abs_limit: i32,
406+
show_all_hidden: bool,
407+
node_limit: i32,
408+
level_limit: i32,
409+
) -> Result<serde_json::Value> {
369410
let params = vec![
370411
("uri".to_string(), uri.to_string()),
371412
("output".to_string(), output.to_string()),
@@ -442,7 +483,13 @@ impl HttpClient {
442483
self.post("/api/v1/search/search", &body).await
443484
}
444485

445-
pub async fn grep(&self, uri: &str, pattern: &str, ignore_case: bool, node_limit: i32) -> Result<serde_json::Value> {
486+
pub async fn grep(
487+
&self,
488+
uri: &str,
489+
pattern: &str,
490+
ignore_case: bool,
491+
node_limit: i32,
492+
) -> Result<serde_json::Value> {
446493
let body = serde_json::json!({
447494
"uri": uri,
448495
"pattern": pattern,
@@ -452,8 +499,12 @@ impl HttpClient {
452499
self.post("/api/v1/search/grep", &body).await
453500
}
454501

455-
456-
pub async fn glob(&self, pattern: &str, uri: &str, node_limit: i32) -> Result<serde_json::Value> {
502+
pub async fn glob(
503+
&self,
504+
pattern: &str,
505+
uri: &str,
506+
node_limit: i32,
507+
) -> Result<serde_json::Value> {
457508
let body = serde_json::json!({
458509
"pattern": pattern,
459510
"uri": uri,
@@ -726,11 +777,7 @@ impl HttpClient {
726777
self.put(&path, &body).await
727778
}
728779

729-
pub async fn admin_regenerate_key(
730-
&self,
731-
account_id: &str,
732-
user_id: &str,
733-
) -> Result<Value> {
780+
pub async fn admin_regenerate_key(&self, account_id: &str, user_id: &str) -> Result<Value> {
734781
let path = format!(
735782
"/api/v1/admin/accounts/{}/users/{}/key",
736783
account_id, user_id
@@ -790,3 +837,47 @@ impl HttpClient {
790837
Ok(count)
791838
}
792839
}
840+
841+
#[cfg(test)]
842+
mod tests {
843+
use super::HttpClient;
844+
845+
#[test]
846+
fn build_headers_includes_tenant_identity_headers() {
847+
let client = HttpClient::new(
848+
"http://localhost:1933",
849+
Some("test-key".to_string()),
850+
Some("assistant-1".to_string()),
851+
Some("acme".to_string()),
852+
Some("alice".to_string()),
853+
5.0,
854+
);
855+
856+
let headers = client.build_headers();
857+
858+
assert_eq!(
859+
headers
860+
.get("X-API-Key")
861+
.and_then(|value| value.to_str().ok()),
862+
Some("test-key")
863+
);
864+
assert_eq!(
865+
headers
866+
.get("X-OpenViking-Agent")
867+
.and_then(|value| value.to_str().ok()),
868+
Some("assistant-1")
869+
);
870+
assert_eq!(
871+
headers
872+
.get("X-OpenViking-Account")
873+
.and_then(|value| value.to_str().ok()),
874+
Some("acme")
875+
);
876+
assert_eq!(
877+
headers
878+
.get("X-OpenViking-User")
879+
.and_then(|value| value.to_str().ok()),
880+
Some("alice")
881+
);
882+
}
883+
}

crates/ov_cli/src/config.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ pub struct Config {
1010
#[serde(default = "default_url")]
1111
pub url: String,
1212
pub api_key: Option<String>,
13+
pub account: Option<String>,
14+
pub user: Option<String>,
1315
pub agent_id: Option<String>,
1416
#[serde(default = "default_timeout")]
1517
pub timeout: f64,
@@ -40,6 +42,8 @@ impl Default for Config {
4042
Self {
4143
url: "http://localhost:1933".to_string(),
4244
api_key: None,
45+
account: None,
46+
user: None,
4347
agent_id: None,
4448
timeout: 60.0,
4549
output: "table".to_string(),
@@ -108,3 +112,26 @@ pub fn get_or_create_machine_id() -> Result<String> {
108112
Err(_) => Ok("default".to_string()),
109113
}
110114
}
115+
116+
#[cfg(test)]
117+
mod tests {
118+
use super::Config;
119+
120+
#[test]
121+
fn config_deserializes_account_and_user_fields() {
122+
let config: Config = serde_json::from_str(
123+
r#"{
124+
"url": "http://localhost:1933",
125+
"api_key": "test-key",
126+
"account": "acme",
127+
"user": "alice",
128+
"agent_id": "assistant-1"
129+
}"#,
130+
)
131+
.expect("config should deserialize");
132+
133+
assert_eq!(config.account.as_deref(), Some("acme"));
134+
assert_eq!(config.user.as_deref(), Some("alice"));
135+
assert_eq!(config.agent_id.as_deref(), Some("assistant-1"));
136+
}
137+
}

0 commit comments

Comments
 (0)