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
231 lines
7.0 KiB
Rust
231 lines
7.0 KiB
Rust
//! Drive service client — files, folders, shares, permissions.
|
|
|
|
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
|
use crate::error::Result;
|
|
use reqwest::Method;
|
|
use super::types::*;
|
|
|
|
/// Client for the La Suite Drive API.
|
|
pub struct DriveClient {
|
|
pub(crate) transport: HttpTransport,
|
|
}
|
|
|
|
impl ServiceClient for DriveClient {
|
|
fn service_name(&self) -> &'static str {
|
|
"drive"
|
|
}
|
|
|
|
fn base_url(&self) -> &str {
|
|
&self.transport.base_url
|
|
}
|
|
|
|
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
|
Self {
|
|
transport: HttpTransport::new(&base_url, auth),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DriveClient {
|
|
/// Build a DriveClient from domain (e.g. `https://drive.{domain}/api/v1.0`).
|
|
pub fn connect(domain: &str) -> Self {
|
|
let base_url = format!("https://drive.{domain}/api/v1.0");
|
|
Self::from_parts(base_url, AuthMethod::Bearer(String::new()))
|
|
}
|
|
|
|
/// Set the bearer token for authentication.
|
|
pub fn with_token(mut self, token: &str) -> Self {
|
|
self.transport.set_auth(AuthMethod::Bearer(token.to_string()));
|
|
self
|
|
}
|
|
|
|
// -- Items --------------------------------------------------------------
|
|
|
|
/// 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>> {
|
|
self.list_items(page, Some("file")).await
|
|
}
|
|
|
|
/// List folders (items with type=folder).
|
|
pub async fn list_folders(&self, page: Option<u32>) -> Result<DRFPage<DriveFolder>> {
|
|
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
|
|
}
|
|
|
|
/// 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, "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.
|
|
pub async fn share_file(&self, id: &str, body: &serde_json::Value) -> Result<FileShare> {
|
|
self.transport
|
|
.json(
|
|
Method::POST,
|
|
&format!("files/{id}/shares/"),
|
|
Some(body),
|
|
"drive share file",
|
|
)
|
|
.await
|
|
}
|
|
|
|
// -- Permissions --------------------------------------------------------
|
|
|
|
/// Get permissions for a file.
|
|
pub async fn get_permissions(&self, id: &str) -> Result<DRFPage<FilePermission>> {
|
|
self.transport
|
|
.json(
|
|
Method::GET,
|
|
&format!("files/{id}/permissions/"),
|
|
Option::<&()>::None,
|
|
"drive get permissions",
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_connect_url() {
|
|
let c = DriveClient::connect("sunbeam.pt");
|
|
assert_eq!(c.base_url(), "https://drive.sunbeam.pt/api/v1.0");
|
|
assert_eq!(c.service_name(), "drive");
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_parts() {
|
|
let c = DriveClient::from_parts(
|
|
"http://localhost:8000/api/v1.0".into(),
|
|
AuthMethod::Bearer("tok".into()),
|
|
);
|
|
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
|
}
|
|
}
|