feat: encrypted vault keystore, JWT auth, Drive upload
Vault keystore (vault_keystore.rs): - AES-256-GCM encrypted local storage for root tokens + unseal keys - Argon2id KDF with machine-specific salt, 0600 permissions - save/load/verify/export API with 26 unit tests - Integrated into seed flow: save after init, load as fallback, backfill from cluster, restore K8s Secret if wiped Vault CLI: - vault reinit: wipe and re-initialize vault with confirmation - vault keys: show local keystore status - vault export-keys: plaintext export for machine migration - vault status: now shows keystore status + uses JWT auth - Fixed seal_status() bypassing request() (missing auth headers) Vault OIDC auth: - JWT auth method enabled on OpenBao via seed script - cli-admin role: full access for users with admin:true JWT claim - cli-reader role: read-only for non-admin SSO users - BaoClient.with_proxy_auth(): sends both Bearer (proxy) and X-Vault-Token (vault) headers - SunbeamClient.bao() authenticates via JWT login, falls back to local keystore root token Drive: - SDK client uses /items/ endpoint (was /files/ and /folders/) - Added create_child, upload_ended, upload_to_s3 methods - Added recursive drive upload command (--path, --folder-id) - Switched all La Suite clients to /external_api/v1.0/ Infrastructure: - Removed openbao-keys-placeholder.yaml from kustomization - Added sunbeam.dev/managed-by label to programmatic secrets - kv_patch→kv_put fallback for fresh vault initialization - Hydra/Kratos secrets combined (new,old) for key rotation
This commit is contained in:
@@ -550,6 +550,15 @@ pub enum DriveCommand {
|
||||
#[command(subcommand)]
|
||||
action: PermissionAction,
|
||||
},
|
||||
/// Upload a local file or directory to a Drive folder.
|
||||
Upload {
|
||||
/// Local path to upload (file or directory).
|
||||
#[arg(short, long)]
|
||||
path: String,
|
||||
/// Target Drive folder ID.
|
||||
#[arg(short = 't', long)]
|
||||
folder_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -687,9 +696,132 @@ pub async fn dispatch_drive(
|
||||
)
|
||||
}
|
||||
},
|
||||
DriveCommand::Upload { path, folder_id } => {
|
||||
upload_recursive(drive, &path, &folder_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively upload a local file or directory to a Drive folder.
|
||||
async fn upload_recursive(
|
||||
drive: &super::DriveClient,
|
||||
local_path: &str,
|
||||
parent_id: &str,
|
||||
) -> Result<()> {
|
||||
let path = std::path::Path::new(local_path);
|
||||
if !path.exists() {
|
||||
return Err(crate::error::SunbeamError::Other(format!(
|
||||
"Path does not exist: {local_path}"
|
||||
)));
|
||||
}
|
||||
|
||||
if path.is_file() {
|
||||
upload_single_file(drive, path, parent_id).await
|
||||
} else if path.is_dir() {
|
||||
upload_directory(drive, path, parent_id).await
|
||||
} else {
|
||||
Err(crate::error::SunbeamError::Other(format!(
|
||||
"Not a file or directory: {local_path}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_directory(
|
||||
drive: &super::DriveClient,
|
||||
dir: &std::path::Path,
|
||||
parent_id: &str,
|
||||
) -> Result<()> {
|
||||
let dir_name = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unnamed");
|
||||
|
||||
output::step(&format!("Creating folder: {dir_name}"));
|
||||
|
||||
// Create the folder in Drive
|
||||
let folder = drive
|
||||
.create_child(
|
||||
parent_id,
|
||||
&serde_json::json!({
|
||||
"title": dir_name,
|
||||
"type": "folder",
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let folder_id = folder["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| crate::error::SunbeamError::Other("No folder ID in response".into()))?;
|
||||
|
||||
// Process entries
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.map_err(|e| crate::error::SunbeamError::Other(format!("reading dir: {e}")))?
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let entry_path = entry.path();
|
||||
if entry_path.is_dir() {
|
||||
Box::pin(upload_directory(drive, &entry_path, folder_id)).await?;
|
||||
} else if entry_path.is_file() {
|
||||
upload_single_file(drive, &entry_path, folder_id).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_single_file(
|
||||
drive: &super::DriveClient,
|
||||
file_path: &std::path::Path,
|
||||
parent_id: &str,
|
||||
) -> Result<()> {
|
||||
let filename = file_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unnamed");
|
||||
|
||||
// Skip hidden files
|
||||
if filename.starts_with('.') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
output::ok(&format!("Uploading: {filename}"));
|
||||
|
||||
// Create the file item in Drive
|
||||
let item = drive
|
||||
.create_child(
|
||||
parent_id,
|
||||
&serde_json::json!({
|
||||
"title": filename,
|
||||
"type": "file",
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let item_id = item["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| crate::error::SunbeamError::Other("No item ID in response".into()))?;
|
||||
|
||||
// Get the presigned upload URL (Drive returns it as "policy" on create)
|
||||
let upload_url = item["policy"]
|
||||
.as_str()
|
||||
.ok_or_else(|| crate::error::SunbeamError::Other("No upload policy URL in response — is the item a file?".into()))?;
|
||||
|
||||
// Read the file and upload to S3
|
||||
let data = std::fs::read(file_path)
|
||||
.map_err(|e| crate::error::SunbeamError::Other(format!("reading file: {e}")))?;
|
||||
drive
|
||||
.upload_to_s3(upload_url, bytes::Bytes::from(data))
|
||||
.await?;
|
||||
|
||||
// Notify Drive the upload is complete
|
||||
drive.upload_ended(item_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Mail (Messages)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -39,70 +39,146 @@ impl DriveClient {
|
||||
self
|
||||
}
|
||||
|
||||
// -- Files --------------------------------------------------------------
|
||||
// -- Items --------------------------------------------------------------
|
||||
|
||||
/// List files with optional pagination.
|
||||
/// List items with optional pagination and type filter.
|
||||
pub async fn list_items(
|
||||
&self,
|
||||
page: Option<u32>,
|
||||
item_type: Option<&str>,
|
||||
) -> Result<DRFPage<DriveFile>> {
|
||||
let mut path = String::from("items/?");
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("page={p}&"));
|
||||
}
|
||||
if let Some(t) = item_type {
|
||||
path.push_str(&format!("type={t}&"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "drive list items")
|
||||
.await
|
||||
}
|
||||
|
||||
/// List files (items with type=file).
|
||||
pub async fn list_files(&self, page: Option<u32>) -> Result<DRFPage<DriveFile>> {
|
||||
let path = match page {
|
||||
Some(p) => format!("files/?page={p}"),
|
||||
None => "files/".to_string(),
|
||||
};
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "drive list files")
|
||||
.await
|
||||
self.list_items(page, Some("file")).await
|
||||
}
|
||||
|
||||
/// Get a single file by ID.
|
||||
pub async fn get_file(&self, id: &str) -> Result<DriveFile> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("files/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"drive get file",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Upload a new file.
|
||||
pub async fn upload_file(&self, body: &serde_json::Value) -> Result<DriveFile> {
|
||||
self.transport
|
||||
.json(Method::POST, "files/", Some(body), "drive upload file")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a file.
|
||||
pub async fn delete_file(&self, id: &str) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("files/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"drive delete file",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Folders ------------------------------------------------------------
|
||||
|
||||
/// List folders with optional pagination.
|
||||
/// List folders (items with type=folder).
|
||||
pub async fn list_folders(&self, page: Option<u32>) -> Result<DRFPage<DriveFolder>> {
|
||||
let path = match page {
|
||||
Some(p) => format!("folders/?page={p}"),
|
||||
None => "folders/".to_string(),
|
||||
};
|
||||
let mut path = String::from("items/?type=folder&");
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("page={p}&"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "drive list folders")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new folder.
|
||||
/// Get a single item by ID.
|
||||
pub async fn get_file(&self, id: &str) -> Result<DriveFile> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("items/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"drive get item",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new item (file or folder) at the root level.
|
||||
pub async fn upload_file(&self, body: &serde_json::Value) -> Result<DriveFile> {
|
||||
self.transport
|
||||
.json(Method::POST, "items/", Some(body), "drive create item")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete an item.
|
||||
pub async fn delete_file(&self, id: &str) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("items/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"drive delete item",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new folder at the root level.
|
||||
pub async fn create_folder(&self, body: &serde_json::Value) -> Result<DriveFolder> {
|
||||
self.transport
|
||||
.json(Method::POST, "folders/", Some(body), "drive create folder")
|
||||
.json(Method::POST, "items/", Some(body), "drive create folder")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Items (children API) ------------------------------------------------
|
||||
|
||||
/// Create a child item under a parent folder.
|
||||
/// Returns the created item including its upload_url for files.
|
||||
pub async fn create_child(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::POST,
|
||||
&format!("items/{parent_id}/children/"),
|
||||
Some(body),
|
||||
"drive create child",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// List children of an item (folder).
|
||||
pub async fn list_children(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
page: Option<u32>,
|
||||
) -> Result<DRFPage<serde_json::Value>> {
|
||||
let path = match page {
|
||||
Some(p) => format!("items/{parent_id}/children/?page={p}"),
|
||||
None => format!("items/{parent_id}/children/"),
|
||||
};
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "drive list children")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Notify Drive that a file upload to S3 is complete.
|
||||
pub async fn upload_ended(&self, item_id: &str) -> Result<serde_json::Value> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::POST,
|
||||
&format!("items/{item_id}/upload-ended/"),
|
||||
Option::<&()>::None,
|
||||
"drive upload ended",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Upload file bytes directly to a presigned S3 URL.
|
||||
pub async fn upload_to_s3(&self, presigned_url: &str, data: bytes::Bytes) -> Result<()> {
|
||||
let resp = reqwest::Client::new()
|
||||
.put(presigned_url)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.body(data)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| crate::error::SunbeamError::network(format!("S3 upload: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(crate::error::SunbeamError::network(format!(
|
||||
"S3 upload: HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Shares -------------------------------------------------------------
|
||||
|
||||
/// Share a file with a user.
|
||||
|
||||
Reference in New Issue
Block a user