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:
2026-03-24 12:09:01 +00:00
parent 13e3f5d42e
commit ca0748b109
13 changed files with 1462 additions and 69 deletions

View File

@@ -335,6 +335,11 @@ impl SunbeamClient {
crate::auth::get_gitea_token()
}
/// Get cached OIDC id_token (JWT with claims including admin flag).
fn id_token(&self) -> Result<String> {
crate::auth::get_id_token()
}
// -- Lazy async accessors (each feature-gated) ---------------------------
//
// Each accessor resolves the appropriate auth and constructs the client
@@ -424,7 +429,7 @@ impl SunbeamClient {
pub async fn people(&self) -> Result<&crate::lasuite::PeopleClient> {
self.people.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://people.{}/api/v1.0", self.domain);
let url = format!("https://people.{}/external_api/v1.0", self.domain);
Ok(crate::lasuite::PeopleClient::from_parts(url, AuthMethod::Bearer(token)))
}).await
}
@@ -433,7 +438,7 @@ impl SunbeamClient {
pub async fn docs(&self) -> Result<&crate::lasuite::DocsClient> {
self.docs.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://docs.{}/api/v1.0", self.domain);
let url = format!("https://docs.{}/external_api/v1.0", self.domain);
Ok(crate::lasuite::DocsClient::from_parts(url, AuthMethod::Bearer(token)))
}).await
}
@@ -442,7 +447,7 @@ impl SunbeamClient {
pub async fn meet(&self) -> Result<&crate::lasuite::MeetClient> {
self.meet.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://meet.{}/api/v1.0", self.domain);
let url = format!("https://meet.{}/external_api/v1.0", self.domain);
Ok(crate::lasuite::MeetClient::from_parts(url, AuthMethod::Bearer(token)))
}).await
}
@@ -451,7 +456,7 @@ impl SunbeamClient {
pub async fn drive(&self) -> Result<&crate::lasuite::DriveClient> {
self.drive.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://drive.{}/api/v1.0", self.domain);
let url = format!("https://drive.{}/external_api/v1.0", self.domain);
Ok(crate::lasuite::DriveClient::from_parts(url, AuthMethod::Bearer(token)))
}).await
}
@@ -460,7 +465,7 @@ impl SunbeamClient {
pub async fn messages(&self) -> Result<&crate::lasuite::MessagesClient> {
self.messages.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://mail.{}/api/v1.0", self.domain);
let url = format!("https://mail.{}/external_api/v1.0", self.domain);
Ok(crate::lasuite::MessagesClient::from_parts(url, AuthMethod::Bearer(token)))
}).await
}
@@ -469,7 +474,7 @@ impl SunbeamClient {
pub async fn calendars(&self) -> Result<&crate::lasuite::CalendarsClient> {
self.calendars.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://calendar.{}/api/v1.0", self.domain);
let url = format!("https://calendar.{}/external_api/v1.0", self.domain);
Ok(crate::lasuite::CalendarsClient::from_parts(url, AuthMethod::Bearer(token)))
}).await
}
@@ -478,16 +483,65 @@ impl SunbeamClient {
pub async fn find(&self) -> Result<&crate::lasuite::FindClient> {
self.find.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://find.{}/api/v1.0", self.domain);
let url = format!("https://find.{}/external_api/v1.0", self.domain);
Ok(crate::lasuite::FindClient::from_parts(url, AuthMethod::Bearer(token)))
}).await
}
pub async fn bao(&self) -> Result<&crate::openbao::BaoClient> {
self.bao.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://vault.{}", self.domain);
Ok(crate::openbao::BaoClient::with_token(&url, &token))
let id_token = self.id_token()?;
let bearer = self.sso_token().await?;
// Authenticate to OpenBao via JWT auth method using the OIDC id_token.
// Try admin role first (for users with admin: true), fall back to reader.
let http = reqwest::Client::new();
let vault_token = {
let mut token = None;
for role in &["cli-admin", "cli-reader"] {
let resp = http
.post(format!("{url}/v1/auth/jwt/login"))
.bearer_auth(&bearer)
.json(&serde_json::json!({ "jwt": id_token, "role": role }))
.send()
.await;
match resp {
Ok(r) => {
let status = r.status();
if status.is_success() {
if let Ok(body) = r.json::<serde_json::Value>().await {
if let Some(t) = body["auth"]["client_token"].as_str() {
tracing::debug!("vault JWT login ok (role={role})");
token = Some(t.to_string());
break;
}
}
} else {
let body = r.text().await.unwrap_or_default();
tracing::debug!("vault JWT login {status} (role={role}): {body}");
}
}
Err(e) => {
tracing::debug!("vault JWT login request failed (role={role}): {e}");
}
}
}
match token {
Some(t) => t,
None => {
tracing::debug!("vault JWT auth failed, falling back to local keystore");
match crate::vault_keystore::load_keystore(&self.domain) {
Ok(ks) => ks.root_token,
Err(_) => return Err(SunbeamError::secrets(
"Vault auth failed: no valid JWT role and no local keystore. Run `sunbeam auth sso` and retry."
)),
}
}
}
};
Ok(crate::openbao::BaoClient::with_proxy_auth(&url, &vault_token, &bearer))
}).await
}
}