mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
refactor(auth): simplify vault credential cleanup
Remove the temporary legacy vault migration and fail-open empty-vault path. Centralize vault secret naming and Codex credential shaping so env and vault resolution share the same behavior, and make extra-header refs use the same explicit vault vocabulary as provider auth refs.
This commit is contained in:
parent
3448a7d067
commit
437a277692
19 changed files with 174 additions and 584 deletions
|
|
@ -16,7 +16,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
|
|||
|
||||
Use `fabro secret set NAME value` for API keys and PAT-style secrets. The default secret type is now `token`; `environment` and `credential` secret schemas are no longer part of the API.
|
||||
|
||||
Server startup performs a best-effort one-time migration for old vault files and writes a backup next to the original vault before rewriting. If migration or loading fails, Fabro logs a warning and continues with an empty in-memory vault. This compatibility shim is scheduled for removal after 2026-08-18.
|
||||
Existing server-owned secrets using the removed `environment` or `credential` schemas must be re-created with the new `token`, `oauth`, or `file` schema.
|
||||
|
||||
## More
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ base_url = "https://llm-gateway.example.com/v1"
|
|||
aliases = ["gateway"]
|
||||
|
||||
[llm.providers.proxy.auth]
|
||||
credentials = ["env:ACME_GATEWAY_API_KEY"]
|
||||
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
|
||||
|
||||
[llm.providers.proxy.extra_headers]
|
||||
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
|
||||
|
|
@ -119,7 +119,7 @@ reasoning = false
|
|||
|
||||
`api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same.
|
||||
|
||||
Provider auth is declared in `[llm.providers.<id>.auth]` with ordered `env:<NAME>` or `vault:<NAME>` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers.<id>.auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only typed headers and no API-key auth — go in `extra_headers` as `{ env = "NAME" }`, `{ credential = "id" }`, or `{ literal = "value" }`.
|
||||
Provider auth is declared in `[llm.providers.<id>.auth]` with ordered `env:<NAME>` or `vault:<NAME>` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers.<id>.auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only typed headers and no API-key auth — go in `extra_headers` as `{ env = "NAME" }`, `{ vault = "NAME" }`, or `{ literal = "value" }`.
|
||||
|
||||
Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, and `gemini`; model-level values override provider-level values.
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ base_url = "https://llm-gateway.example.com/v1"
|
|||
aliases = ["gateway"]
|
||||
|
||||
[llm.providers.proxy.auth]
|
||||
credentials = ["env:ACME_GATEWAY_API_KEY"]
|
||||
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
|
||||
|
||||
[llm.providers.proxy.extra_headers]
|
||||
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
|
||||
|
|
@ -182,12 +182,12 @@ enabled = true
|
|||
aliases = ["gateway"]
|
||||
|
||||
[llm.providers.proxy.auth]
|
||||
credentials = ["env:ACME_GATEWAY_API_KEY"]
|
||||
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
|
||||
|
||||
[llm.providers.proxy.extra_headers]
|
||||
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
|
||||
x-portkey-config = { literal = "@bedrock-prod" }
|
||||
x-team-secret = { credential = "gateway_team_secret" }
|
||||
x-team-secret = { vault = "gateway_team_secret" }
|
||||
```
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|
|
@ -200,7 +200,7 @@ x-team-secret = { credential = "gateway_team_secret" }
|
|||
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
|
||||
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>` and `env:<NAME>`. Literal secret strings are rejected. |
|
||||
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
|
||||
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ credential = "id" }`. |
|
||||
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ vault = "NAME" }`. |
|
||||
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
|
||||
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
|
||||
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use fabro_model::{Catalog, CredentialRef, HeaderValueRef, ProviderId};
|
|||
use fabro_static::EnvVars;
|
||||
|
||||
use crate::credential_source::{CredentialSource, ResolvedCredentials};
|
||||
use crate::resolve::{apply_openai_api_env_context, apply_openai_codex_api_context};
|
||||
use crate::{ApiCredential, EnvLookup, ResolveError, build_api_key_header};
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -64,15 +65,10 @@ impl EnvCredentialSource {
|
|||
project_id: None,
|
||||
};
|
||||
if provider.id == ProviderId::openai() && cred.auth_header.is_some() {
|
||||
cred.org_id = self.lookup(EnvVars::OPENAI_ORG_ID);
|
||||
cred.project_id = self.lookup(EnvVars::OPENAI_PROJECT_ID);
|
||||
if let Some(account_id) = self.lookup(EnvVars::CHATGPT_ACCOUNT_ID) {
|
||||
cred.base_url = Some("https://chatgpt.com/backend-api/codex".to_string());
|
||||
cred.codex_mode = true;
|
||||
cred.extra_headers
|
||||
.insert("ChatGPT-Account-Id".to_string(), account_id);
|
||||
cred.extra_headers
|
||||
.insert("originator".to_string(), "fabro".to_string());
|
||||
apply_openai_codex_api_context(&mut cred, Some(&account_id), &*self.env_lookup);
|
||||
} else {
|
||||
apply_openai_api_env_context(&mut cred, &*self.env_lookup);
|
||||
}
|
||||
}
|
||||
Ok(Some(cred))
|
||||
|
|
@ -89,7 +85,7 @@ impl EnvCredentialSource {
|
|||
let value = match value_ref {
|
||||
HeaderValueRef::Literal(value) => Some(value.clone()),
|
||||
HeaderValueRef::Env(name) => self.lookup(name),
|
||||
HeaderValueRef::Credential(_) => None,
|
||||
HeaderValueRef::Vault(_) => None,
|
||||
}
|
||||
.ok_or_else(|| ResolveError::NotConfigured(provider.id.clone()))?;
|
||||
Ok((name.clone(), value))
|
||||
|
|
|
|||
|
|
@ -28,3 +28,5 @@ pub use vault_ext::{
|
|||
VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_set_token,
|
||||
};
|
||||
pub use vault_source::VaultCredentialSource;
|
||||
|
||||
pub const OPENAI_CODEX_VAULT_SECRET_NAME: &str = "OPENAI_CODEX";
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use crate::credential::{ApiKeyHeader, OAuthCredential};
|
|||
use crate::credential_source::CredentialSource;
|
||||
use crate::env_source::EnvCredentialSource;
|
||||
use crate::refresh::refresh_oauth_credential;
|
||||
use crate::vault_ext::vault_set_oauth;
|
||||
use crate::vault_ext::{VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth};
|
||||
|
||||
pub type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
||||
|
|
@ -75,6 +75,38 @@ impl ApiCredential {
|
|||
}
|
||||
}
|
||||
|
||||
const OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
|
||||
const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id";
|
||||
const ORIGINATOR_HEADER: &str = "originator";
|
||||
const FABRO_ORIGINATOR: &str = "fabro";
|
||||
|
||||
pub(crate) fn apply_openai_api_env_context(
|
||||
credential: &mut ApiCredential,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Send + Sync),
|
||||
) {
|
||||
credential.org_id = env_lookup(EnvVars::OPENAI_ORG_ID);
|
||||
credential.project_id = env_lookup(EnvVars::OPENAI_PROJECT_ID);
|
||||
}
|
||||
|
||||
pub(crate) fn apply_openai_codex_api_context(
|
||||
credential: &mut ApiCredential,
|
||||
account_id: Option<&str>,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Send + Sync),
|
||||
) {
|
||||
apply_openai_api_env_context(credential, env_lookup);
|
||||
if let Some(account_id) = account_id {
|
||||
credential.extra_headers.insert(
|
||||
CHATGPT_ACCOUNT_ID_HEADER.to_string(),
|
||||
account_id.to_string(),
|
||||
);
|
||||
}
|
||||
credential
|
||||
.extra_headers
|
||||
.insert(ORIGINATOR_HEADER.to_string(), FABRO_ORIGINATOR.to_string());
|
||||
credential.base_url = Some(OPENAI_CODEX_BASE_URL.to_string());
|
||||
credential.codex_mode = true;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build_api_key_header(policy: ApiKeyHeaderPolicy, key: String) -> ApiKeyHeader {
|
||||
match policy {
|
||||
|
|
@ -306,31 +338,22 @@ impl CredentialResolver {
|
|||
credential_ref: &CredentialRef,
|
||||
) -> Result<Option<ResolvedSecret>, ResolveError> {
|
||||
match credential_ref {
|
||||
CredentialRef::Vault(name) => {
|
||||
let Some(entry) = vault.get_entry(name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match entry.secret_type {
|
||||
SecretType::Token => Ok(Some(ResolvedSecret::ApiKey(entry.value.clone()))),
|
||||
SecretType::Oauth => serde_json::from_str(&entry.value)
|
||||
.map(|credential| {
|
||||
Some(ResolvedSecret::OAuth {
|
||||
credential: Box::new(credential),
|
||||
vault_name: name.clone(),
|
||||
})
|
||||
CredentialRef::Vault(name) => match vault_get_token(vault, name) {
|
||||
Ok(Some(token)) => Ok(Some(ResolvedSecret::ApiKey(token))),
|
||||
Ok(None) => Ok(None),
|
||||
Err(VaultLookupError::SchemaMismatch {
|
||||
actual: SecretType::Oauth,
|
||||
..
|
||||
}) => vault_get_oauth(vault, name)
|
||||
.map(|credential| {
|
||||
credential.map(|credential| ResolvedSecret::OAuth {
|
||||
credential: Box::new(credential),
|
||||
vault_name: name.clone(),
|
||||
})
|
||||
.map_err(|source| ResolveError::VaultDecodeFailed {
|
||||
provider: provider.clone(),
|
||||
name: name.clone(),
|
||||
source,
|
||||
}),
|
||||
SecretType::File => Err(ResolveError::VaultSchemaMismatch {
|
||||
provider: provider.clone(),
|
||||
name: name.clone(),
|
||||
actual: SecretType::File,
|
||||
}),
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|err| vault_lookup_error(provider, name, err)),
|
||||
Err(err) => Err(vault_lookup_error(provider, name, err)),
|
||||
},
|
||||
CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)),
|
||||
}
|
||||
}
|
||||
|
|
@ -361,7 +384,7 @@ impl CredentialResolver {
|
|||
let value = match value_ref {
|
||||
HeaderValueRef::Literal(value) => Some(value.clone()),
|
||||
HeaderValueRef::Env(name) => self.lookup_env(name),
|
||||
HeaderValueRef::Credential(name) => vault.get(name).map(str::to_string),
|
||||
HeaderValueRef::Vault(name) => vault.get(name).map(str::to_string),
|
||||
}
|
||||
.ok_or_else(|| ResolveError::NotConfigured(provider.clone()))?;
|
||||
Ok((name.clone(), value))
|
||||
|
|
@ -396,8 +419,7 @@ impl CredentialResolver {
|
|||
cred.extra_headers =
|
||||
self.resolved_extra_headers_for_catalog(vault, provider_id, catalog)?;
|
||||
if provider_id == &ProviderId::openai() {
|
||||
cred.org_id = self.lookup_env(EnvVars::OPENAI_ORG_ID);
|
||||
cred.project_id = self.lookup_env(EnvVars::OPENAI_PROJECT_ID);
|
||||
apply_openai_api_env_context(&mut cred, &*self.env_lookup);
|
||||
}
|
||||
Ok(cred)
|
||||
}
|
||||
|
|
@ -414,19 +436,11 @@ impl CredentialResolver {
|
|||
project_id: None,
|
||||
};
|
||||
if provider_id == &ProviderId::openai() {
|
||||
if let Some(account_id) = &credential.account_id {
|
||||
api_credential
|
||||
.extra_headers
|
||||
.insert("ChatGPT-Account-Id".to_string(), account_id.clone());
|
||||
}
|
||||
api_credential
|
||||
.extra_headers
|
||||
.insert("originator".to_string(), "fabro".to_string());
|
||||
api_credential.base_url =
|
||||
Some("https://chatgpt.com/backend-api/codex".to_string());
|
||||
api_credential.codex_mode = true;
|
||||
api_credential.org_id = self.lookup_env(EnvVars::OPENAI_ORG_ID);
|
||||
api_credential.project_id = self.lookup_env(EnvVars::OPENAI_PROJECT_ID);
|
||||
apply_openai_codex_api_context(
|
||||
&mut api_credential,
|
||||
credential.account_id.as_deref(),
|
||||
&*self.env_lookup,
|
||||
);
|
||||
}
|
||||
Ok(api_credential)
|
||||
}
|
||||
|
|
@ -499,6 +513,21 @@ impl CredentialResolver {
|
|||
}
|
||||
}
|
||||
|
||||
fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError {
|
||||
match err {
|
||||
VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch {
|
||||
provider: provider.clone(),
|
||||
name: name.to_string(),
|
||||
actual,
|
||||
},
|
||||
VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed {
|
||||
provider: provider.clone(),
|
||||
name: name.to_string(),
|
||||
source,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn configured_providers_from_process_env(
|
||||
vault: Option<&Arc<AsyncRwLock<Vault>>>,
|
||||
catalog: &Catalog,
|
||||
|
|
@ -614,7 +643,7 @@ mod tests {
|
|||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
"OPENAI_CODEX",
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
"https://auth.openai.com/oauth/token".to_string(),
|
||||
Utc::now() + Duration::hours(1),
|
||||
|
|
@ -754,7 +783,7 @@ reasoning = false
|
|||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
"OPENAI_CODEX",
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
"https://auth.openai.com/oauth/token".to_string(),
|
||||
Utc::now() + Duration::hours(1),
|
||||
|
|
@ -1028,7 +1057,7 @@ reasoning = false
|
|||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
"OPENAI_CODEX",
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
server.url("/oauth/token"),
|
||||
Utc::now() - Duration::minutes(1),
|
||||
|
|
@ -1058,7 +1087,9 @@ reasoning = false
|
|||
|
||||
let stored = {
|
||||
let vault = vault.read().await;
|
||||
vault_get_oauth(&vault, "OPENAI_CODEX").unwrap().unwrap()
|
||||
vault_get_oauth(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(stored.tokens.access_token, "new-access");
|
||||
assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh"));
|
||||
|
|
@ -1075,7 +1106,12 @@ reasoning = false
|
|||
Utc::now() - Duration::minutes(1),
|
||||
);
|
||||
credential.tokens.refresh_token = None;
|
||||
vault_set_oauth(&mut vault, "OPENAI_CODEX", &credential).unwrap();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&credential,
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
let catalog = default_catalog();
|
||||
|
||||
|
|
|
|||
|
|
@ -130,8 +130,13 @@ mod tests {
|
|||
#[test]
|
||||
fn vault_get_token_errors_on_oauth_entry() {
|
||||
let mut vault = temp_vault();
|
||||
vault_set_oauth(&mut vault, "OPENAI_CODEX", &fixture()).unwrap();
|
||||
let err = vault_get_token(&vault, "OPENAI_CODEX").unwrap_err();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&fixture(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = vault_get_token(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME).unwrap_err();
|
||||
assert!(matches!(err, VaultLookupError::SchemaMismatch { .. }));
|
||||
}
|
||||
|
||||
|
|
@ -139,9 +144,16 @@ mod tests {
|
|||
fn vault_get_oauth_round_trips() {
|
||||
let mut vault = temp_vault();
|
||||
let credential = fixture();
|
||||
vault_set_oauth(&mut vault, "OPENAI_CODEX", &credential).unwrap();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&credential,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vault_get_oauth(&vault, "OPENAI_CODEX").unwrap().unwrap(),
|
||||
vault_get_oauth(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
credential,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,12 @@ mod tests {
|
|||
async fn resolve_returns_credentials_and_auth_issues() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_oauth(&mut vault, "OPENAI_CODEX", &expired_openai_credential()).unwrap();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&expired_openai_credential(),
|
||||
)
|
||||
.unwrap();
|
||||
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
|
||||
|
||||
let source =
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use dialoguer::console::Term;
|
|||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::{MultiSelect, Select};
|
||||
use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType};
|
||||
use fabro_auth::{AuthMethod, LoginResult, codex_oauth_config};
|
||||
use fabro_auth::{AuthMethod, LoginResult, OPENAI_CODEX_VAULT_SECRET_NAME, codex_oauth_config};
|
||||
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget};
|
||||
use fabro_config::bind::Bind;
|
||||
use fabro_config::daemon::ServerDaemon;
|
||||
|
|
@ -108,18 +108,10 @@ fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
|
|||
}
|
||||
|
||||
fn provider_vault_secret_name(provider: &ProviderId, catalog: &Catalog) -> String {
|
||||
catalog
|
||||
.provider(provider)
|
||||
.and_then(|provider| provider.auth.as_ref())
|
||||
.and_then(|auth| {
|
||||
auth.credentials
|
||||
.iter()
|
||||
.find_map(|credential| match credential {
|
||||
CredentialRef::Vault(name) => Some(name.clone()),
|
||||
CredentialRef::Env(_) => None,
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase()))
|
||||
catalog.provider_vault_secret_name(provider).map_or_else(
|
||||
|| format!("{}_API_KEY", provider.to_string().to_uppercase()),
|
||||
str::to_string,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1236,7 +1228,7 @@ fn credential_secret_request(result: &LoginResult) -> Result<CreateSecretRequest
|
|||
description: None,
|
||||
}),
|
||||
LoginResult::OAuth { credential, .. } => Ok(CreateSecretRequest {
|
||||
name: "OPENAI_CODEX".to_string(),
|
||||
name: OPENAI_CODEX_VAULT_SECRET_NAME.to_string(),
|
||||
value: serde_json::to_string(credential)?,
|
||||
type_: ApiSecretType::Oauth,
|
||||
description: None,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_api::types;
|
||||
use fabro_auth::LoginResult;
|
||||
use fabro_model::{Catalog, CredentialRef, ProviderId};
|
||||
use fabro_auth::{LoginResult, OPENAI_CODEX_VAULT_SECRET_NAME};
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::ProviderLoginArgs;
|
||||
|
|
@ -38,11 +37,17 @@ pub(super) async fn login_command(
|
|||
|
||||
let (name, value, type_) = match result {
|
||||
LoginResult::ApiKey { provider, key } => {
|
||||
let name = api_key_secret_name(&provider, ctx.catalog()?.as_ref());
|
||||
let name = ctx
|
||||
.catalog()?
|
||||
.provider_vault_secret_name(&provider)
|
||||
.with_context(|| {
|
||||
format!("provider '{provider}' does not define a vault credential path")
|
||||
})?
|
||||
.to_string();
|
||||
(name, key, types::SecretType::Token)
|
||||
}
|
||||
LoginResult::OAuth { credential, .. } => (
|
||||
"OPENAI_CODEX".to_string(),
|
||||
OPENAI_CODEX_VAULT_SECRET_NAME.to_string(),
|
||||
serde_json::to_string(&credential)?,
|
||||
types::SecretType::Oauth,
|
||||
),
|
||||
|
|
@ -59,18 +64,3 @@ pub(super) async fn login_command(
|
|||
fabro_util::printerr!(printer, " {} Saved {}", s.green.apply_to("✔"), name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api_key_secret_name(provider: &ProviderId, catalog: &Catalog) -> String {
|
||||
catalog
|
||||
.provider(provider)
|
||||
.and_then(|provider| provider.auth.as_ref())
|
||||
.and_then(|auth| {
|
||||
auth.credentials
|
||||
.iter()
|
||||
.find_map(|credential_ref| match credential_ref {
|
||||
CredentialRef::Vault(name) => Some(name.clone()),
|
||||
CredentialRef::Env(_) => None,
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -367,8 +367,8 @@ agent_profile = "gemini"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn header_value_ref_parses_credential_form() {
|
||||
let parsed: HeaderValueRef = toml::from_str(r#"value = { credential = "portkey_config" }"#)
|
||||
fn header_value_ref_parses_vault_form() {
|
||||
let parsed: HeaderValueRef = toml::from_str(r#"value = { vault = "portkey_config" }"#)
|
||||
.map(|v: toml::Value| {
|
||||
v.as_table()
|
||||
.unwrap()
|
||||
|
|
@ -380,10 +380,7 @@ agent_profile = "gemini"
|
|||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
HeaderValueRef::Credential("portkey_config".to_string())
|
||||
);
|
||||
assert_eq!(parsed, HeaderValueRef::Vault("portkey_config".to_string()));
|
||||
assert_eq!(parsed.to_string(), "vault:portkey_config");
|
||||
}
|
||||
|
||||
|
|
@ -457,7 +454,7 @@ agent_profile = "gemini"
|
|||
for source in [
|
||||
r#"value = { literal = "" }"#,
|
||||
r#"value = { env = "" }"#,
|
||||
r#"value = { credential = "" }"#,
|
||||
r#"value = { vault = "" }"#,
|
||||
] {
|
||||
let err = toml::from_str::<Wrap>(source).unwrap_err();
|
||||
assert!(err.to_string().contains("must not be empty"));
|
||||
|
|
@ -509,7 +506,7 @@ base_url = "https://api.portkey.ai/v1"
|
|||
[providers.portkey.extra_headers]
|
||||
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
|
||||
x-portkey-provider = { literal = "@bedrock-prod" }
|
||||
x-portkey-config = { credential = "portkey_config" }
|
||||
x-portkey-config = { vault = "portkey_config" }
|
||||
"#;
|
||||
|
||||
let layer: LlmLayer = toml::from_str(toml).unwrap();
|
||||
|
|
@ -527,7 +524,7 @@ x-portkey-config = { credential = "portkey_config" }
|
|||
);
|
||||
assert_eq!(
|
||||
headers.get("x-portkey-config"),
|
||||
Some(&HeaderValueRef::Credential("portkey_config".to_string())),
|
||||
Some(&HeaderValueRef::Vault("portkey_config".to_string())),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,12 +227,12 @@ enabled = true
|
|||
aliases = ["gateway"]
|
||||
|
||||
[llm.providers.proxy.auth]
|
||||
credentials = ["env:ACME_GATEWAY_API_KEY"]
|
||||
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
|
||||
|
||||
[llm.providers.proxy.extra_headers]
|
||||
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
|
||||
x-portkey-config = { literal = "@bedrock-prod" }
|
||||
x-team-secret = { credential = "gateway_team_secret" }
|
||||
x-team-secret = { vault = "gateway_team_secret" }
|
||||
```
|
||||
|
||||
| Key | Type / values | Default | Description |
|
||||
|
|
@ -245,7 +245,7 @@ x-team-secret = { credential = "gateway_team_secret" }
|
|||
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
|
||||
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>` and `env:<NAME>`. Literal secret strings are rejected. |
|
||||
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
|
||||
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ credential = "id" }`. |
|
||||
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ vault = "NAME" }`. |
|
||||
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
|
||||
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
|
||||
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ pub enum BillingPolicy {
|
|||
pub enum HeaderValueRef {
|
||||
Literal(String),
|
||||
Env(String),
|
||||
Credential(String),
|
||||
Vault(String),
|
||||
}
|
||||
|
||||
impl Serialize for HeaderValueRef {
|
||||
|
|
@ -327,7 +327,7 @@ impl Serialize for HeaderValueRef {
|
|||
match self {
|
||||
Self::Literal(value) => map.serialize_entry("literal", value)?,
|
||||
Self::Env(value) => map.serialize_entry("env", value)?,
|
||||
Self::Credential(value) => map.serialize_entry("credential", value)?,
|
||||
Self::Vault(value) => map.serialize_entry("vault", value)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
|
@ -338,7 +338,7 @@ impl std::fmt::Display for HeaderValueRef {
|
|||
match self {
|
||||
Self::Literal(_) => f.write_str("literal:<redacted>"),
|
||||
Self::Env(name) => write!(f, "env:{name}"),
|
||||
Self::Credential(id) => write!(f, "vault:{id}"),
|
||||
Self::Vault(id) => write!(f, "vault:{id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -354,11 +354,11 @@ enum HeaderValueRefInput {
|
|||
#[serde(deny_unknown_fields)]
|
||||
struct HeaderValueRefSerde {
|
||||
#[serde(default)]
|
||||
literal: Option<String>,
|
||||
literal: Option<String>,
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
env: Option<String>,
|
||||
#[serde(default)]
|
||||
credential: Option<String>,
|
||||
vault: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for HeaderValueRef {
|
||||
|
|
@ -385,7 +385,7 @@ impl TryFrom<HeaderValueRefSerde> for HeaderValueRef {
|
|||
let populated = [
|
||||
value.literal.as_ref(),
|
||||
value.env.as_ref(),
|
||||
value.credential.as_ref(),
|
||||
value.vault.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
|
|
@ -399,8 +399,8 @@ impl TryFrom<HeaderValueRefSerde> for HeaderValueRef {
|
|||
if let Some(value) = value.env {
|
||||
return non_empty_header_value(value).map(Self::Env);
|
||||
}
|
||||
if let Some(value) = value.credential {
|
||||
return non_empty_header_value(value).map(Self::Credential);
|
||||
if let Some(value) = value.vault {
|
||||
return non_empty_header_value(value).map(Self::Vault);
|
||||
}
|
||||
unreachable!("populated field count was already checked");
|
||||
}
|
||||
|
|
@ -416,7 +416,7 @@ fn non_empty_header_value(value: String) -> Result<String, HeaderValueRefParseEr
|
|||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum HeaderValueRefParseError {
|
||||
#[error("header value must contain exactly one of `literal`, `env`, or `credential`")]
|
||||
#[error("header value must contain exactly one of `literal`, `env`, or `vault`")]
|
||||
WrongFieldCount,
|
||||
#[error("header value reference must not be empty")]
|
||||
EmptyValue,
|
||||
|
|
@ -785,6 +785,19 @@ impl Catalog {
|
|||
.and_then(|idx| self.providers.get(*idx))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider_vault_secret_name(&self, id: &ProviderId) -> Option<&str> {
|
||||
self.provider(id)?
|
||||
.auth
|
||||
.as_ref()?
|
||||
.credentials
|
||||
.iter()
|
||||
.find_map(|credential_ref| match credential_ref {
|
||||
CredentialRef::Vault(name) => Some(name.as_str()),
|
||||
CredentialRef::Env(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model_settings(&self, id: &str) -> Option<&CatalogModelSettings> {
|
||||
let model = self.get(id)?;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ use fabro_install::{
|
|||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::generate::{GenerateParams, generate};
|
||||
use fabro_model::catalog::CatalogProvider;
|
||||
use fabro_model::{Catalog, CredentialRef, ProviderId};
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_sandbox::daytona;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::ArtifactStore;
|
||||
|
|
@ -838,18 +838,10 @@ fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogPro
|
|||
}
|
||||
|
||||
fn provider_secret_name(provider: &ProviderId) -> Result<String, String> {
|
||||
let catalog_provider = install_catalog_provider(provider)?;
|
||||
catalog_provider
|
||||
.auth
|
||||
.as_ref()
|
||||
.and_then(|auth| {
|
||||
auth.credentials
|
||||
.iter()
|
||||
.find_map(|credential_ref| match credential_ref {
|
||||
CredentialRef::Vault(name) => Some(name.clone()),
|
||||
CredentialRef::Env(_) => None,
|
||||
})
|
||||
})
|
||||
install_catalog_provider(provider)?;
|
||||
INSTALL_CATALOG
|
||||
.provider_vault_secret_name(provider)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("provider '{provider}' does not define a vault credential path"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ mod startup;
|
|||
pub mod static_files;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
mod vault_legacy_migration;
|
||||
pub mod web_auth;
|
||||
mod worker_token;
|
||||
|
||||
|
|
|
|||
|
|
@ -137,8 +137,7 @@ use crate::server_secrets::{LlmClientResult, ServerSecrets};
|
|||
use crate::spawn_env::{apply_render_graph_env, apply_worker_env};
|
||||
use crate::worker_token::{WorkerTokenKeys, issue_worker_token};
|
||||
use crate::{
|
||||
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files,
|
||||
vault_legacy_migration, web_auth,
|
||||
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
|
||||
};
|
||||
|
||||
mod handler;
|
||||
|
|
@ -1557,40 +1556,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
shutdown,
|
||||
} = config;
|
||||
|
||||
match vault_legacy_migration::migrate_legacy_vault_file(&vault_path) {
|
||||
Ok(report) if report.changed() => {
|
||||
let backup_path = report
|
||||
.backup_path
|
||||
.as_ref()
|
||||
.map_or_else(|| "<none>".to_string(), |path| path.display().to_string());
|
||||
warn!(
|
||||
migrated_entries = report.migrated_entries,
|
||||
skipped_entries = report.skipped_entries,
|
||||
backup_path = %backup_path,
|
||||
removal_deadline = vault_legacy_migration::REMOVAL_DEADLINE,
|
||||
"Migrated legacy vault file"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
removal_deadline = vault_legacy_migration::REMOVAL_DEADLINE,
|
||||
"Legacy vault migration failed; continuing with normal vault load"
|
||||
);
|
||||
}
|
||||
}
|
||||
let vault = match Vault::load(vault_path.clone()) {
|
||||
Ok(vault) => vault,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
removal_deadline = vault_legacy_migration::REMOVAL_DEADLINE,
|
||||
"Vault load failed after legacy migration; continuing with empty vault"
|
||||
);
|
||||
Vault::empty(vault_path)
|
||||
}
|
||||
};
|
||||
let vault = Vault::load(vault_path.clone())
|
||||
.with_context(|| format!("load vault {}", vault_path.display()))?;
|
||||
let vault = Arc::new(AsyncRwLock::new(vault));
|
||||
let llm_source: Arc<dyn CredentialSource> = Arc::new(VaultCredentialSource::with_env_lookup(
|
||||
Arc::clone(&vault),
|
||||
|
|
|
|||
|
|
@ -1879,135 +1879,6 @@ methods = ["dev-token"]
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_app_state_migrates_legacy_vault_file_on_boot() {
|
||||
let vault_path = test_secret_store_path();
|
||||
let timestamp = "2026-05-18T12:00:00Z";
|
||||
let legacy_api_key = json!({
|
||||
"provider": "anthropic",
|
||||
"type": "api_key",
|
||||
"key": "sk-ant-legacy",
|
||||
});
|
||||
let legacy_oauth = json!({
|
||||
"provider": "openai",
|
||||
"type": "codex_oauth",
|
||||
"tokens": {
|
||||
"access_token": "codex-access",
|
||||
"refresh_token": "codex-refresh",
|
||||
"expires_at": "2026-05-18T13:00:00Z",
|
||||
},
|
||||
"config": {
|
||||
"auth_url": "https://auth.openai.com",
|
||||
"token_url": "https://auth.openai.com/oauth/token",
|
||||
"client_id": "client",
|
||||
"scopes": ["openid", "offline_access"],
|
||||
"redirect_uri": "https://auth.openai.com/deviceauth/callback",
|
||||
"use_pkce": false,
|
||||
},
|
||||
"account_id": "acct_legacy",
|
||||
});
|
||||
let legacy_vault = json!({
|
||||
"anthropic": {
|
||||
"value": legacy_api_key.to_string(),
|
||||
"type": "credential",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
},
|
||||
"openai_codex": {
|
||||
"value": legacy_oauth.to_string(),
|
||||
"type": "credential",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
},
|
||||
"GITHUB_TOKEN": {
|
||||
"value": "ghp_legacy",
|
||||
"type": "environment",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
},
|
||||
"/tmp/github.pem": {
|
||||
"value": "/tmp/github.pem",
|
||||
"type": "file",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
},
|
||||
});
|
||||
std::fs::write(
|
||||
&vault_path,
|
||||
serde_json::to_vec_pretty(&legacy_vault).unwrap(),
|
||||
)
|
||||
.expect("legacy vault should be writable");
|
||||
|
||||
let state = build_test_app_state_with_vault_path(&vault_path)
|
||||
.expect("legacy vault should not prevent server boot");
|
||||
|
||||
let vault = state
|
||||
.vault
|
||||
.try_read()
|
||||
.expect("test vault should not be locked");
|
||||
let api_key_entry = vault
|
||||
.get_entry("ANTHROPIC_API_KEY")
|
||||
.expect("legacy provider credential should be migrated to token name");
|
||||
assert_eq!(api_key_entry.secret_type, SecretType::Token);
|
||||
assert_eq!(api_key_entry.value, "sk-ant-legacy");
|
||||
assert!(vault.get_entry("anthropic").is_none());
|
||||
|
||||
let oauth_entry = vault
|
||||
.get_entry("OPENAI_CODEX")
|
||||
.expect("legacy Codex credential should be migrated to canonical OAuth name");
|
||||
assert_eq!(oauth_entry.secret_type, SecretType::Oauth);
|
||||
let oauth: fabro_auth::OAuthCredential =
|
||||
serde_json::from_str(&oauth_entry.value).expect("migrated OAuth JSON should parse");
|
||||
assert_eq!(oauth.tokens.access_token, "codex-access");
|
||||
assert_eq!(oauth.account_id.as_deref(), Some("acct_legacy"));
|
||||
assert!(vault.get_entry("openai_codex").is_none());
|
||||
|
||||
assert_eq!(
|
||||
vault.get_entry("GITHUB_TOKEN").unwrap().secret_type,
|
||||
SecretType::Token
|
||||
);
|
||||
assert_eq!(
|
||||
vault.get_entry("/tmp/github.pem").unwrap().secret_type,
|
||||
SecretType::File
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_app_state_continues_with_empty_vault_when_legacy_migration_fails() {
|
||||
let vault_path = test_secret_store_path();
|
||||
std::fs::write(&vault_path, "{").expect("malformed legacy vault should be writable");
|
||||
|
||||
let state = build_test_app_state_with_vault_path(&vault_path)
|
||||
.expect("legacy migration failure should not prevent server boot");
|
||||
|
||||
assert!(state.vault.try_read().unwrap().list().is_empty());
|
||||
}
|
||||
|
||||
fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result<Arc<AppState>> {
|
||||
let (store, artifact_store) = test_store_bundle();
|
||||
build_app_state(AppStateConfig {
|
||||
resolved_settings: resolved_runtime_settings_for_tests(
|
||||
default_test_server_settings(),
|
||||
RunLayer::default(),
|
||||
LlmCatalogSettings::default(),
|
||||
),
|
||||
registry_factory_override: None,
|
||||
max_concurrent_runs: 5,
|
||||
store,
|
||||
artifact_store,
|
||||
vault_path: vault_path.to_path_buf(),
|
||||
server_secrets: load_test_server_secrets(
|
||||
vault_path.with_file_name("server.env"),
|
||||
HashMap::new(),
|
||||
),
|
||||
env_lookup: default_env_lookup(),
|
||||
github_api_base_url: None,
|
||||
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
|
||||
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
||||
shutdown: tokio_util::sync::CancellationToken::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_command_test_state(
|
||||
storage_dir: &Path,
|
||||
methods: &[&str],
|
||||
|
|
|
|||
|
|
@ -1,275 +0,0 @@
|
|||
//! Temporary compatibility shim for pre-token/oauth vault files.
|
||||
//!
|
||||
//! Delete this module after 2026-08-18, once supported installs have had a
|
||||
//! release window to rewrite `credential` / `environment` entries to the
|
||||
//! `oauth` / `token` schemas.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Temporary startup migration uses synchronous vault file I/O before serving requests."
|
||||
)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use fabro_auth::{OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(crate) const REMOVAL_DEADLINE: &str = "2026-08-18";
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct LegacyVaultMigrationReport {
|
||||
pub(crate) migrated_entries: usize,
|
||||
pub(crate) skipped_entries: usize,
|
||||
pub(crate) backup_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl LegacyVaultMigrationReport {
|
||||
pub(crate) fn changed(&self) -> bool {
|
||||
self.migrated_entries > 0 || self.skipped_entries > 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LegacyAuthCredential {
|
||||
provider: String,
|
||||
#[serde(flatten)]
|
||||
details: LegacyAuthDetails,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum LegacyAuthDetails {
|
||||
ApiKey {
|
||||
key: String,
|
||||
},
|
||||
CodexOauth {
|
||||
tokens: OAuthTokens,
|
||||
config: OAuthConfig,
|
||||
#[serde(default)]
|
||||
account_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_legacy_vault_file(path: &Path) -> anyhow::Result<LegacyVaultMigrationReport> {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(LegacyVaultMigrationReport::default());
|
||||
}
|
||||
Err(err) => return Err(err).with_context(|| format!("read vault {}", path.display())),
|
||||
};
|
||||
let entries = parse_vault_entries(&contents)?;
|
||||
let (next_entries, migrated_entries, skipped_entries) = rewrite_entries(entries);
|
||||
let mut report = LegacyVaultMigrationReport {
|
||||
migrated_entries,
|
||||
skipped_entries,
|
||||
backup_path: None,
|
||||
};
|
||||
if !report.changed() {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
let backup_path = backup_vault_file(path)?;
|
||||
write_vault_entries(path, &next_entries)?;
|
||||
report.backup_path = Some(backup_path);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn parse_vault_entries(contents: &str) -> anyhow::Result<Map<String, Value>> {
|
||||
let value: Value = serde_json::from_str(contents).context("parse vault JSON")?;
|
||||
match value {
|
||||
Value::Object(entries) => Ok(entries),
|
||||
_ => bail!("vault JSON root must be an object"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_entries(entries: Map<String, Value>) -> (Map<String, Value>, usize, usize) {
|
||||
let mut next_entries = Map::new();
|
||||
let mut occupied = HashSet::new();
|
||||
for (name, entry) in &entries {
|
||||
if matches!(
|
||||
entry.get("type").and_then(Value::as_str),
|
||||
Some("token" | "oauth" | "file")
|
||||
) {
|
||||
next_entries.insert(name.clone(), entry.clone());
|
||||
occupied.insert(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut migrated_entries = 0;
|
||||
let mut skipped_entries = 0;
|
||||
for (name, entry) in entries {
|
||||
match entry.get("type").and_then(Value::as_str) {
|
||||
Some("token" | "oauth" | "file") => {}
|
||||
Some("environment") => {
|
||||
if insert_rewritten_entry(
|
||||
&mut next_entries,
|
||||
&mut occupied,
|
||||
name,
|
||||
rewrite_entry(entry, "token", None),
|
||||
) {
|
||||
migrated_entries += 1;
|
||||
} else {
|
||||
skipped_entries += 1;
|
||||
}
|
||||
}
|
||||
Some("credential") => match legacy_credential_entry(&name, &entry) {
|
||||
Some((target_name, rewritten)) => {
|
||||
if insert_rewritten_entry(
|
||||
&mut next_entries,
|
||||
&mut occupied,
|
||||
target_name,
|
||||
rewritten,
|
||||
) {
|
||||
migrated_entries += 1;
|
||||
} else {
|
||||
skipped_entries += 1;
|
||||
}
|
||||
}
|
||||
None => skipped_entries += 1,
|
||||
},
|
||||
_ => skipped_entries += 1,
|
||||
}
|
||||
}
|
||||
|
||||
(next_entries, migrated_entries, skipped_entries)
|
||||
}
|
||||
|
||||
fn insert_rewritten_entry(
|
||||
entries: &mut Map<String, Value>,
|
||||
occupied: &mut HashSet<String>,
|
||||
name: String,
|
||||
entry: Value,
|
||||
) -> bool {
|
||||
if !occupied.insert(name.clone()) {
|
||||
return false;
|
||||
}
|
||||
entries.insert(name, entry);
|
||||
true
|
||||
}
|
||||
|
||||
fn legacy_credential_entry(name: &str, entry: &Value) -> Option<(String, Value)> {
|
||||
let value = entry.get("value").and_then(Value::as_str)?;
|
||||
let credential: LegacyAuthCredential = serde_json::from_str(value).ok()?;
|
||||
match credential.details {
|
||||
LegacyAuthDetails::ApiKey { key } => {
|
||||
let target_name = api_key_secret_name(&credential.provider)?;
|
||||
Some((
|
||||
target_name,
|
||||
rewrite_entry(entry.clone(), "token", Some(key)),
|
||||
))
|
||||
}
|
||||
LegacyAuthDetails::CodexOauth {
|
||||
tokens,
|
||||
config,
|
||||
account_id,
|
||||
} if credential.provider == "openai" && name == "openai_codex" => {
|
||||
let credential = OAuthCredential {
|
||||
tokens,
|
||||
config,
|
||||
account_id,
|
||||
};
|
||||
let value = serde_json::to_string(&credential).ok()?;
|
||||
Some((
|
||||
"OPENAI_CODEX".to_string(),
|
||||
rewrite_entry(entry.clone(), "oauth", Some(value)),
|
||||
))
|
||||
}
|
||||
LegacyAuthDetails::CodexOauth { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_entry(mut entry: Value, secret_type: &str, value: Option<String>) -> Value {
|
||||
if let Value::Object(fields) = &mut entry {
|
||||
fields.insert("type".to_string(), Value::String(secret_type.to_string()));
|
||||
if let Some(value) = value {
|
||||
fields.insert("value".to_string(), Value::String(value));
|
||||
}
|
||||
}
|
||||
entry
|
||||
}
|
||||
|
||||
fn api_key_secret_name(provider: &str) -> Option<String> {
|
||||
let mut name = String::new();
|
||||
for ch in provider.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
name.push(ch.to_ascii_uppercase());
|
||||
} else if !name.ends_with('_') {
|
||||
name.push('_');
|
||||
}
|
||||
}
|
||||
while name.ends_with('_') {
|
||||
name.pop();
|
||||
}
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !name.ends_with("_API_KEY") {
|
||||
name.push_str("_API_KEY");
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
|
||||
fn backup_vault_file(path: &Path) -> anyhow::Result<PathBuf> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("secrets.json");
|
||||
let backup_path = parent.join(format!(
|
||||
".{file_name}.legacy-vault-migration-{}.bak",
|
||||
ulid::Ulid::new()
|
||||
));
|
||||
std::fs::copy(path, &backup_path).with_context(|| {
|
||||
format!(
|
||||
"copy vault {} to backup {}",
|
||||
path.display(),
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
set_private_permissions(&backup_path)?;
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
fn write_vault_entries(path: &Path, entries: &Map<String, Value>) -> anyhow::Result<()> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create vault directory {}", parent.display()))?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("secrets.json");
|
||||
let tmp_path = parent.join(format!(
|
||||
".{file_name}.legacy-vault-migration-tmp-{}",
|
||||
ulid::Ulid::new()
|
||||
));
|
||||
let json = serde_json::to_vec_pretty(entries).context("serialize migrated vault JSON")?;
|
||||
std::fs::write(&tmp_path, json)
|
||||
.with_context(|| format!("write migrated vault temp file {}", tmp_path.display()))?;
|
||||
set_private_permissions(&tmp_path)?;
|
||||
std::fs::rename(&tmp_path, path).with_context(|| {
|
||||
format!(
|
||||
"rename migrated vault temp file {} to {}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private_permissions(path: &Path) -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
.with_context(|| format!("set private permissions on {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_private_permissions(_path: &Path) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -62,13 +62,6 @@ pub struct Vault {
|
|||
}
|
||||
|
||||
impl Vault {
|
||||
pub fn empty(path: PathBuf) -> Self {
|
||||
Self {
|
||||
path,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: PathBuf) -> Result<Self, Error> {
|
||||
let entries = match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => serde_json::from_str(&contents)?,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue