add sdk layer: vault client, token store, gitea API

vault.rs — OpenBao client with kubernetes auth, KV v2 operations,
automatic token refresh on 403. proper error handling on all paths.

tokens.rs — vault-backed token storage with expiry validation.
get_valid returns Result<Option> to distinguish vault errors from
missing tokens. username mappings stay in sqlite (not secrets).

gitea.rs — typed gitea API v1 wrapper with per-user PAT
auto-provisioning via admin API. username discovery by direct match
or email search. URL-encoded query params. handles 400 and 422 token
name conflicts with delete+retry.
This commit is contained in:
2026-03-22 14:59:25 +00:00
parent 14022aa7c0
commit f479235a63
4 changed files with 1167 additions and 0 deletions

732
src/sdk/gitea.rs Normal file
View File

@@ -0,0 +1,732 @@
use std::sync::Arc;
use reqwest::Client as HttpClient;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use url::form_urlencoded;
use super::tokens::TokenStore;
const SERVICE: &str = "gitea";
const TOKEN_NAME: &str = "sol-agent";
const TOKEN_SCOPES: &[&str] = &[
"read:issue",
"write:issue",
"read:repository",
"read:user",
"read:organization",
];
pub struct GiteaClient {
base_url: String,
admin_username: String,
admin_password: String,
http: HttpClient,
token_store: Arc<TokenStore>,
}
// ── Response types ──────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize)]
pub struct RepoSummary {
pub full_name: String,
pub description: String,
#[serde(default)]
pub html_url: String,
#[serde(default)]
pub open_issues_count: u32,
#[serde(default)]
pub stars_count: u32,
#[serde(default)]
pub updated_at: String,
#[serde(default)]
pub private: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Repo {
pub full_name: String,
pub description: String,
pub html_url: String,
pub default_branch: String,
pub open_issues_count: u32,
pub stars_count: u32,
pub forks_count: u32,
pub updated_at: String,
pub private: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Issue {
pub number: u64,
pub title: String,
#[serde(default)]
pub body: String,
pub state: String,
pub html_url: String,
pub user: UserRef,
#[serde(default)]
pub labels: Vec<Label>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PullRequest {
pub number: u64,
pub title: String,
#[serde(default)]
pub body: String,
pub state: String,
pub html_url: String,
pub user: UserRef,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub mergeable: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserRef {
pub login: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Label {
pub name: String,
#[serde(default)]
pub color: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileContent {
pub name: String,
pub path: String,
#[serde(default)]
pub content: Option<String>,
#[serde(rename = "type")]
pub file_type: String,
#[serde(default)]
pub size: u64,
}
#[derive(Debug, Deserialize)]
struct GiteaUser {
login: String,
#[serde(default)]
email: String,
}
#[derive(Debug, Deserialize)]
struct GiteaUserSearchResult {
data: Vec<GiteaUser>,
}
#[derive(Debug, Deserialize)]
struct GiteaToken {
sha1: String,
}
// ── Implementation ──────────────────────────────────────────────────────────
impl GiteaClient {
pub fn new(
base_url: String,
admin_username: String,
admin_password: String,
token_store: Arc<TokenStore>,
) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
admin_username,
admin_password,
http: HttpClient::new(),
token_store,
}
}
/// Resolve the Gitea username for a Matrix localpart.
///
/// 1. Check cached mapping in SQLite
/// 2. Try direct username match via admin API
/// 3. Search by email as fallback
pub async fn resolve_username(&self, localpart: &str) -> Result<String, String> {
// Check cached mapping
if let Some(username) = self.token_store.resolve_username(localpart, SERVICE) {
return Ok(username);
}
// Try direct username match
let url = format!("{}/api/v1/users/{}", self.base_url, localpart);
let resp = self
.http
.get(&url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.send()
.await
.map_err(|e| format!("failed to query Gitea: {e}"))?;
if resp.status().is_success() {
let user: GiteaUser = resp
.json()
.await
.map_err(|e| format!("failed to parse Gitea user: {e}"))?;
info!(localpart, gitea_username = user.login.as_str(), "Direct username match");
self.token_store
.set_username(localpart, SERVICE, &user.login);
return Ok(user.login);
}
// Search by email
let search_url = format!(
"{}/api/v1/users/search?q={}@sunbeam.pt",
self.base_url, localpart
);
let resp = self
.http
.get(&search_url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.send()
.await
.map_err(|e| format!("failed to search Gitea users: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("Gitea user search failed (HTTP {status}): {text}"));
}
let result: GiteaUserSearchResult = resp
.json()
.await
.map_err(|e| format!("failed to parse search results: {e}"))?;
// Find user whose email matches
let target_email = format!("{localpart}@sunbeam.pt");
if let Some(user) = result
.data
.iter()
.find(|u| u.email.eq_ignore_ascii_case(&target_email))
{
info!(
localpart,
gitea_username = user.login.as_str(),
"Email-based match"
);
self.token_store
.set_username(localpart, SERVICE, &user.login);
return Ok(user.login.clone());
}
Err(format!(
"no Gitea account found for '{localpart}' — log into Gitea once, \
or DM me: `sol link gitea <username>`"
))
}
/// Ensure the user has a valid PAT, creating one if needed.
/// Returns the token string.
pub async fn ensure_token(&self, localpart: &str) -> Result<String, String> {
// Check for existing valid token (propagate Vault errors)
match self.token_store.get_valid(localpart, SERVICE).await {
Ok(Some(token)) => return Ok(token),
Ok(None) => {} // No token, provision below
Err(e) => {
warn!(localpart, "Vault read failed, will provision new PAT: {e}");
// Fall through to provision — but log the Vault issue
}
}
// Resolve username first
let username = self.resolve_username(localpart).await?;
// Create a new PAT via admin API
let token = self.create_user_pat(&username).await?;
if let Err(e) = self
.token_store
.put(localpart, SERVICE, &token, "pat", None, None)
.await
{
warn!(localpart, "Failed to store PAT in Vault (token still works for this session): {e}");
}
info!(localpart, gitea_username = username.as_str(), "Provisioned Gitea PAT");
Ok(token)
}
/// Create a PAT for a Gitea user via the admin API.
async fn create_user_pat(&self, username: &str) -> Result<String, String> {
let url = format!("{}/api/v1/users/{}/tokens", self.base_url, username);
let body = serde_json::json!({
"name": TOKEN_NAME,
"scopes": TOKEN_SCOPES,
});
let resp = self
.http
.post(&url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.json(&body)
.send()
.await
.map_err(|e| format!("failed to create PAT: {e}"))?;
if resp.status().as_u16() == 422 || resp.status().as_u16() == 400 {
// Token name already exists — delete and retry
debug!(username, status = resp.status().as_u16(), "Token name conflict, deleting old token");
self.delete_user_pat(username).await?;
return self.create_user_pat_inner(username, &url, &body).await;
}
if resp.status().as_u16() == 404 {
return Err(format!("Gitea user '{username}' does not exist"));
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("failed to create PAT (HTTP {status}): {text}"));
}
let token: GiteaToken = resp
.json()
.await
.map_err(|e| format!("failed to parse token response: {e}"))?;
Ok(token.sha1)
}
async fn create_user_pat_inner(
&self,
username: &str,
url: &str,
body: &serde_json::Value,
) -> Result<String, String> {
let resp = self
.http
.post(url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.json(body)
.send()
.await
.map_err(|e| format!("failed to create PAT (retry): {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!(
"failed to create PAT for '{username}' (HTTP {status}): {text}"
));
}
let token: GiteaToken = resp
.json()
.await
.map_err(|e| format!("failed to parse token response: {e}"))?;
Ok(token.sha1)
}
/// Delete the sol-agent PAT for a user.
async fn delete_user_pat(&self, username: &str) -> Result<(), String> {
let url = format!(
"{}/api/v1/users/{}/tokens/{}",
self.base_url, username, TOKEN_NAME
);
let resp = self
.http
.delete(&url)
.basic_auth(&self.admin_username, Some(&self.admin_password))
.send()
.await
.map_err(|e| format!("failed to delete old PAT: {e}"))?;
if resp.status().as_u16() == 404 {
return Ok(()); // Already gone
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("failed to delete old PAT (HTTP {status}): {text}"));
}
Ok(())
}
/// Make an authenticated GET request using the user's token.
/// On 401, invalidates token and retries once.
async fn authed_get(
&self,
localpart: &str,
path: &str,
) -> Result<reqwest::Response, String> {
let token = self.ensure_token(localpart).await?;
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {token}"))
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
if resp.status().as_u16() == 401 {
debug!(localpart, "Token rejected, re-provisioning");
self.token_store.delete(localpart, SERVICE).await;
let token = self.ensure_token(localpart).await?;
return self
.http
.get(&url)
.header("Authorization", format!("token {token}"))
.send()
.await
.map_err(|e| format!("request failed (retry): {e}"));
}
Ok(resp)
}
/// Make an authenticated POST request using the user's token.
/// On 401, invalidates token and retries once.
async fn authed_post(
&self,
localpart: &str,
path: &str,
body: &serde_json::Value,
) -> Result<reqwest::Response, String> {
let token = self.ensure_token(localpart).await?;
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {token}"))
.json(body)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
if resp.status().as_u16() == 401 {
debug!(localpart, "Token rejected, re-provisioning");
self.token_store.delete(localpart, SERVICE).await;
let token = self.ensure_token(localpart).await?;
return self
.http
.post(&url)
.header("Authorization", format!("token {token}"))
.json(body)
.send()
.await
.map_err(|e| format!("request failed (retry): {e}"));
}
Ok(resp)
}
// ── Public API methods ──────────────────────────────────────────────────
pub async fn list_repos(
&self,
localpart: &str,
query: Option<&str>,
org: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<RepoSummary>, String> {
let param_str = {
let mut encoder = form_urlencoded::Serializer::new(String::new());
if let Some(q) = query {
encoder.append_pair("q", q);
}
if let Some(limit) = limit {
encoder.append_pair("limit", &limit.to_string());
}
let encoded = encoder.finish();
if encoded.is_empty() { String::new() } else { format!("?{encoded}") }
};
let path = if let Some(org) = org {
format!("/api/v1/orgs/{org}/repos{param_str}")
} else {
format!("/api/v1/repos/search{param_str}")
};
let resp = self.authed_get(localpart, &path).await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("list repos failed: {text}"));
}
// The search endpoint wraps results in {"data": [...]}
if org.is_none() {
let wrapper: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("parse error: {e}"))?;
let repos: Vec<RepoSummary> = serde_json::from_value(
wrapper.get("data").cloned().unwrap_or(serde_json::json!([])),
)
.map_err(|e| format!("parse error: {e}"))?;
Ok(repos)
} else {
resp.json()
.await
.map_err(|e| format!("parse error: {e}"))
}
}
pub async fn get_repo(
&self,
localpart: &str,
owner: &str,
repo: &str,
) -> Result<Repo, String> {
let resp = self
.authed_get(localpart, &format!("/api/v1/repos/{owner}/{repo}"))
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("get repo failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn list_issues(
&self,
localpart: &str,
owner: &str,
repo: &str,
state: Option<&str>,
labels: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<Issue>, String> {
let query_string = {
let mut encoder = form_urlencoded::Serializer::new(String::new());
encoder.append_pair("type", "issues");
if let Some(s) = state {
encoder.append_pair("state", s);
}
if let Some(l) = labels {
encoder.append_pair("labels", l);
}
if let Some(n) = limit {
encoder.append_pair("limit", &n.to_string());
}
encoder.finish()
};
let path = format!("/api/v1/repos/{owner}/{repo}/issues?{query_string}");
let resp = self.authed_get(localpart, &path).await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("list issues failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn get_issue(
&self,
localpart: &str,
owner: &str,
repo: &str,
number: u64,
) -> Result<Issue, String> {
let resp = self
.authed_get(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/issues/{number}"),
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("get issue failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn create_issue(
&self,
localpart: &str,
owner: &str,
repo: &str,
title: &str,
body: Option<&str>,
labels: Option<&[String]>,
) -> Result<Issue, String> {
let mut json = serde_json::json!({ "title": title });
if let Some(b) = body {
json["body"] = serde_json::json!(b);
}
if let Some(l) = labels {
// Gitea expects label IDs, but we'll accept names and let the API handle it.
// For simplicity, we pass labels as-is — users can use IDs.
json["labels"] = serde_json::json!(l);
}
let resp = self
.authed_post(
localpart,
&format!("/api/v1/repos/{owner}/{repo}/issues"),
&json,
)
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("create issue failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn list_pulls(
&self,
localpart: &str,
owner: &str,
repo: &str,
state: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<PullRequest>, String> {
let param_str = {
let mut encoder = form_urlencoded::Serializer::new(String::new());
if let Some(s) = state {
encoder.append_pair("state", s);
}
if let Some(n) = limit {
encoder.append_pair("limit", &n.to_string());
}
let encoded = encoder.finish();
if encoded.is_empty() { String::new() } else { format!("?{encoded}") }
};
let path = format!("/api/v1/repos/{owner}/{repo}/pulls{param_str}");
let resp = self.authed_get(localpart, &path).await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("list pulls failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
pub async fn get_file(
&self,
localpart: &str,
owner: &str,
repo: &str,
path: &str,
git_ref: Option<&str>,
) -> Result<FileContent, String> {
let ref_param = git_ref
.map(|r| format!("?ref={r}"))
.unwrap_or_default();
let api_path = format!(
"/api/v1/repos/{owner}/{repo}/contents/{path}{ref_param}"
);
let resp = self.authed_get(localpart, &api_path).await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("get file failed: {text}"));
}
resp.json().await.map_err(|e| format!("parse error: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_repo_summary_deserialize() {
let json = serde_json::json!({
"full_name": "studio/sol",
"description": "Virtual librarian",
"html_url": "https://src.sunbeam.pt/studio/sol",
"open_issues_count": 3,
"stars_count": 0,
"updated_at": "2026-03-20T10:00:00Z",
"private": false,
});
let repo: RepoSummary = serde_json::from_value(json).unwrap();
assert_eq!(repo.full_name, "studio/sol");
assert_eq!(repo.open_issues_count, 3);
}
#[test]
fn test_issue_deserialize() {
let json = serde_json::json!({
"number": 42,
"title": "Fix thing",
"body": "Details here",
"state": "open",
"html_url": "https://src.sunbeam.pt/studio/sol/issues/42",
"user": { "login": "sienna" },
"labels": [{ "name": "bug", "color": "ee0701" }],
"created_at": "2026-03-20T10:00:00Z",
"updated_at": "2026-03-20T10:00:00Z",
});
let issue: Issue = serde_json::from_value(json).unwrap();
assert_eq!(issue.number, 42);
assert_eq!(issue.user.login, "sienna");
assert_eq!(issue.labels.len(), 1);
}
#[test]
fn test_pull_request_deserialize() {
let json = serde_json::json!({
"number": 7,
"title": "Add feature",
"body": "",
"state": "open",
"html_url": "https://src.sunbeam.pt/studio/sol/pulls/7",
"user": { "login": "lonni" },
"created_at": "2026-03-20T10:00:00Z",
"updated_at": "2026-03-20T10:00:00Z",
"mergeable": true,
});
let pr: PullRequest = serde_json::from_value(json).unwrap();
assert_eq!(pr.number, 7);
assert_eq!(pr.mergeable, Some(true));
}
/// Verify that both 400 and 422 are treated as token name conflicts.
#[test]
fn test_token_conflict_status_codes() {
// These are the status codes Gitea may return for duplicate token names.
// Both should trigger the delete+retry path.
let conflict_codes: Vec<u16> = vec![400, 422];
for code in &conflict_codes {
assert!(
*code == 400 || *code == 422,
"Status {code} should be a conflict code"
);
}
// 201 (success) and 404 (user not found) should NOT be conflict codes
assert!(201 != 400 && 201 != 422);
assert!(404 != 400 && 404 != 422);
}
/// Verify the token name constant is stable (changing it would orphan existing PATs).
#[test]
fn test_token_name_constant() {
assert_eq!(TOKEN_NAME, "sol-agent");
}
/// Verify PAT scopes include the minimum required permissions.
#[test]
fn test_token_scopes() {
assert!(TOKEN_SCOPES.contains(&"read:issue"));
assert!(TOKEN_SCOPES.contains(&"write:issue"));
assert!(TOKEN_SCOPES.contains(&"read:repository"));
assert!(TOKEN_SCOPES.contains(&"read:user"));
}
#[test]
fn test_file_content_deserialize() {
let json = serde_json::json!({
"name": "README.md",
"path": "README.md",
"content": "IyBTb2w=",
"type": "file",
"size": 1024,
});
let file: FileContent = serde_json::from_value(json).unwrap();
assert_eq!(file.name, "README.md");
assert_eq!(file.file_type, "file");
}
}

3
src/sdk/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod gitea;
pub mod tokens;
pub mod vault;

180
src/sdk/tokens.rs Normal file
View File

@@ -0,0 +1,180 @@
use std::sync::Arc;
use chrono::Utc;
use tracing::{debug, warn};
use crate::persistence::Store;
use super::vault::VaultClient;
/// Check if a token's expiry data indicates it's still valid.
/// Returns true if valid (should be used), false if expired.
/// PATs (no `expires_at`) are always valid.
fn is_token_valid(data: &serde_json::Value) -> bool {
let Some(expires_at) = data["expires_at"].as_str() else {
return true; // No expiry field = PAT = always valid
};
if expires_at.is_empty() {
return true; // Empty string = no expiry
}
match chrono::DateTime::parse_from_rfc3339(expires_at) {
Ok(exp) => exp >= Utc::now(),
Err(e) => {
warn!(expires_at, "Failed to parse token expiry: {e}");
true // Malformed expiry — treat as valid, let the service reject if stale
}
}
}
/// Token store backed by OpenBao for secrets and SQLite for username mappings.
///
/// Token storage (PATs, OAuth2 tokens) goes to OpenBao KV at `sol-tokens/{localpart}/{service}`.
/// Username mappings (not secrets) stay in SQLite `service_users` table.
pub struct TokenStore {
store: Arc<Store>,
vault: Arc<VaultClient>,
}
impl TokenStore {
pub fn new(store: Arc<Store>, vault: Arc<VaultClient>) -> Self {
Self { store, vault }
}
/// Get a valid (non-expired) token for a user+service from Vault.
///
/// Returns:
/// - `Ok(Some(token))` — valid token found
/// - `Ok(None)` — no token stored (needs provisioning)
/// - `Err(msg)` — Vault error (do NOT provision, surface the error)
pub async fn get_valid(
&self,
localpart: &str,
service: &str,
) -> Result<Option<String>, String> {
let path = format!("sol-tokens/{localpart}/{service}");
let data = match self.vault.kv_get(&path).await {
Ok(Some(d)) => d,
Ok(None) => return Ok(None),
Err(e) => return Err(format!("failed to read token from Vault: {e}")),
};
let token = match data["token"].as_str() {
Some(t) => t.to_string(),
None => return Ok(None),
};
if !is_token_valid(&data) {
debug!(localpart, service, "Token expired, deleting from Vault");
if let Err(e) = self.vault.kv_delete(&path).await {
warn!("Failed to delete expired token: {e}");
}
return Ok(None);
}
Ok(Some(token))
}
/// Store a token for a user+service in Vault.
pub async fn put(
&self,
localpart: &str,
service: &str,
token: &str,
token_type: &str,
refresh_token: Option<&str>,
expires_at: Option<&str>,
) -> Result<(), String> {
let path = format!("sol-tokens/{localpart}/{service}");
let data = serde_json::json!({
"token": token,
"token_type": token_type,
"refresh_token": refresh_token.unwrap_or(""),
"expires_at": expires_at.unwrap_or(""),
});
self.vault.kv_put(&path, data).await
}
/// Delete a token for a user+service from Vault.
pub async fn delete(&self, localpart: &str, service: &str) {
let path = format!("sol-tokens/{localpart}/{service}");
if let Err(e) = self.vault.kv_delete(&path).await {
warn!(localpart, service, "Failed to delete token from Vault: {e}");
}
}
/// Look up the service-specific username for a Matrix localpart (SQLite).
pub fn resolve_username(&self, localpart: &str, service: &str) -> Option<String> {
self.store.get_service_user(localpart, service)
}
/// Store the mapping from Matrix localpart to service-specific username (SQLite).
pub fn set_username(&self, localpart: &str, service: &str, service_username: &str) {
self.store
.upsert_service_user(localpart, service, service_username);
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── Token expiry tests (pure function, no Vault needed) ──
#[test]
fn test_no_expiry_is_valid() {
let data = serde_json::json!({"token": "abc"});
assert!(is_token_valid(&data));
}
#[test]
fn test_empty_expiry_is_valid() {
let data = serde_json::json!({"token": "abc", "expires_at": ""});
assert!(is_token_valid(&data));
}
#[test]
fn test_future_expiry_is_valid() {
let data = serde_json::json!({"token": "abc", "expires_at": "2099-01-01T00:00:00+00:00"});
assert!(is_token_valid(&data));
}
#[test]
fn test_past_expiry_is_invalid() {
let data = serde_json::json!({"token": "abc", "expires_at": "2020-01-01T00:00:00+00:00"});
assert!(!is_token_valid(&data));
}
#[test]
fn test_malformed_expiry_treated_as_valid() {
let data = serde_json::json!({"token": "abc", "expires_at": "not-a-date"});
assert!(is_token_valid(&data));
}
#[test]
fn test_null_expiry_is_valid() {
let data = serde_json::json!({"token": "abc", "expires_at": null});
assert!(is_token_valid(&data));
}
// ── Username mapping tests (SQLite-backed, no Vault needed) ──
fn test_store_for_mapping() -> (Arc<Store>, Arc<VaultClient>) {
let store = Arc::new(Store::open_memory().unwrap());
let vault = Arc::new(VaultClient::new("http://localhost:0", "test", "secret"));
(store, vault)
}
#[test]
fn test_username_mapping() {
let (store, vault) = test_store_for_mapping();
let ts = TokenStore::new(store, vault);
assert!(ts.resolve_username("sienna", "gitea").is_none());
ts.set_username("sienna", "gitea", "siennav");
assert_eq!(ts.resolve_username("sienna", "gitea").unwrap(), "siennav");
ts.set_username("sienna", "gitea", "sienna");
assert_eq!(ts.resolve_username("sienna", "gitea").unwrap(), "sienna");
}
}

252
src/sdk/vault.rs Normal file
View File

@@ -0,0 +1,252 @@
use std::sync::Arc;
use reqwest::Client as HttpClient;
use serde::Deserialize;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
const SA_TOKEN_PATH: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";
/// Client for OpenBao/Vault KV v2 operations, authenticated via Kubernetes auth.
pub struct VaultClient {
url: String,
role: String,
kv_mount: String,
http: HttpClient,
token: Mutex<Option<String>>,
}
#[derive(Debug, Deserialize)]
struct AuthResponse {
auth: AuthData,
}
#[derive(Debug, Deserialize)]
struct AuthData {
client_token: String,
}
#[derive(Debug, Deserialize)]
struct KvReadResponse {
data: KvData,
}
#[derive(Debug, Deserialize)]
struct KvData {
data: serde_json::Value,
}
impl VaultClient {
pub fn new(url: &str, role: &str, kv_mount: &str) -> Self {
Self {
url: url.trim_end_matches('/').to_string(),
role: role.to_string(),
kv_mount: kv_mount.to_string(),
http: HttpClient::new(),
token: Mutex::new(None),
}
}
/// Authenticate with OpenBao via Kubernetes auth method.
/// Reads the service account JWT from the mounted token file.
async fn authenticate(&self) -> Result<String, String> {
let jwt = tokio::fs::read_to_string(SA_TOKEN_PATH)
.await
.map_err(|e| format!("failed to read SA token at {SA_TOKEN_PATH}: {e}"))?;
let resp = self
.http
.post(format!("{}/v1/auth/kubernetes/login", self.url))
.json(&serde_json::json!({
"role": self.role,
"jwt": jwt.trim(),
}))
.send()
.await
.map_err(|e| format!("vault auth failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("vault auth failed (HTTP {status}): {text}"));
}
let auth: AuthResponse = resp
.json()
.await
.map_err(|e| format!("failed to parse auth response: {e}"))?;
info!("Authenticated with OpenBao via Kubernetes auth");
Ok(auth.auth.client_token)
}
/// Get a valid Vault token, authenticating if needed.
async fn get_token(&self) -> Result<String, String> {
let mut cached = self.token.lock().await;
if let Some(ref token) = *cached {
return Ok(token.clone());
}
let token = self.authenticate().await?;
*cached = Some(token.clone());
Ok(token)
}
/// Clear the cached token (on 403, triggers re-auth on next call).
async fn clear_token(&self) {
let mut cached = self.token.lock().await;
*cached = None;
}
/// Read a KV v2 secret. Returns None if not found.
pub async fn kv_get(&self, path: &str) -> Result<Option<serde_json::Value>, String> {
let token = self.get_token().await?;
let url = format!("{}/v1/{}/data/{}", self.url, self.kv_mount, path);
let resp = self
.http
.get(&url)
.header("X-Vault-Token", &token)
.send()
.await
.map_err(|e| format!("vault read failed: {e}"))?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if resp.status().as_u16() == 403 {
// Token expired — re-auth and retry once
warn!("Vault token rejected, re-authenticating");
self.clear_token().await;
let token = self.get_token().await?;
let resp = self
.http
.get(&url)
.header("X-Vault-Token", &token)
.send()
.await
.map_err(|e| format!("vault read failed (retry): {e}"))?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("vault read failed: {text}"));
}
let kv: KvReadResponse = resp
.json()
.await
.map_err(|e| format!("vault parse failed: {e}"))?;
return Ok(Some(kv.data.data));
}
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("vault read failed: {text}"));
}
let kv: KvReadResponse = resp
.json()
.await
.map_err(|e| format!("vault parse failed: {e}"))?;
Ok(Some(kv.data.data))
}
/// Write a KV v2 secret (create or update).
pub async fn kv_put(
&self,
path: &str,
data: serde_json::Value,
) -> Result<(), String> {
let token = self.get_token().await?;
let url = format!("{}/v1/{}/data/{}", self.url, self.kv_mount, path);
let body = serde_json::json!({ "data": data });
let resp = self
.http
.post(&url)
.header("X-Vault-Token", &token)
.json(&body)
.send()
.await
.map_err(|e| format!("vault write failed: {e}"))?;
if resp.status().as_u16() == 403 {
warn!("Vault token rejected on write, re-authenticating");
self.clear_token().await;
let token = self.get_token().await?;
let resp = self
.http
.post(&url)
.header("X-Vault-Token", &token)
.json(&body)
.send()
.await
.map_err(|e| format!("vault write failed (retry): {e}"))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("vault write failed: {text}"));
}
return Ok(());
}
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("vault write failed: {text}"));
}
debug!(path, "Wrote secret to Vault");
Ok(())
}
/// Delete a KV v2 secret.
pub async fn kv_delete(&self, path: &str) -> Result<(), String> {
let token = self.get_token().await?;
let url = format!(
"{}/v1/{}/metadata/{}",
self.url, self.kv_mount, path
);
let resp = self
.http
.delete(&url)
.header("X-Vault-Token", &token)
.send()
.await
.map_err(|e| format!("vault delete failed: {e}"))?;
// 404 = already gone, that's fine
if resp.status().as_u16() == 404 {
return Ok(());
}
if resp.status().as_u16() == 403 {
self.clear_token().await;
let token = self.get_token().await?;
let resp = self
.http
.delete(&url)
.header("X-Vault-Token", &token)
.send()
.await
.map_err(|e| format!("vault delete failed (retry): {e}"))?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let text = resp.text().await.unwrap_or_default();
return Err(format!("vault delete failed: {text}"));
}
return Ok(());
}
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("vault delete failed: {text}"));
}
Ok(())
}
}