Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
use std::collections::HashMap;
use std::time::Duration;
use async_trait::async_trait;
use anyhow::Result;
use chrono::{DateTime, Utc};
use vec1::Vec1;
use warp_graphql::managed_secrets::{ManagedSecret, ManagedSecretConfig, ManagedSecretType};
pub use warp_graphql::queries::task_secrets::ManagedSecretValue;
/// An OIDC identity token issued for a task workload.
#[derive(Debug, Clone)]
pub struct TaskIdentityToken {
/// The signed OIDC JWT.
pub token: String,
/// When the token expires.
pub expires_at: DateTime<Utc>,
/// The OIDC issuer that signed the token.
pub issuer: String,
}
/// Options for issuing an OIDC identity token.
pub struct IdentityTokenOptions {
/// The intended audience for the token (e.g. a cloud provider URL).
pub audience: String,
/// The requested token lifetime. The server may cap this to a maximum value.
pub requested_duration: Duration,
/// Controls how the `sub` claim is formatted. Each element names a claim to
/// include.
pub subject_template: Vec1<String>,
}
/// Configuration for all managed secret stores accessible to the current user.
#[derive(Debug)]
pub struct ManagedSecretConfigs {
/// Configuration for the user's personal secrets.
pub user_secrets: Option<ManagedSecretConfig>,
/// Configuration for all team secret stores that the user can access.
pub team_secrets: HashMap<String, ManagedSecretConfig>,
}
#[derive(Debug, Clone)]
pub enum SecretOwner {
CurrentUser,
Team { team_uid: String },
}
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
pub trait ManagedSecretsClient: 'static + Send + Sync {
async fn get_managed_secret_configs(&self) -> Result<ManagedSecretConfigs>;
async fn create_managed_secret(
&self,
owner: SecretOwner,
name: String,
secret_type: ManagedSecretType,
encrypted_value: String,
description: Option<String>,
) -> Result<ManagedSecret>;
async fn delete_managed_secret(&self, owner: SecretOwner, name: String) -> Result<()>;
async fn update_managed_secret(
&self,
owner: SecretOwner,
name: String,
encrypted_value: Option<String>,
description: Option<String>,
) -> Result<ManagedSecret>;
async fn list_secrets(&self) -> Result<Vec<ManagedSecret>>;
async fn get_task_secrets(
&self,
task_id: String,
workload_token: String,
) -> Result<HashMap<String, ManagedSecretValue>>;
/// Issue a short-lived OIDC identity token for the current task.
///
/// The workload token is not passed explicitly - it's automatically provided
/// as part of the client's cloud agent workload identity token support
/// (see the `ServerApi` implementation).
async fn issue_task_identity_token(
&self,
options: IdentityTokenOptions,
) -> Result<TaskIdentityToken>;
}
+103
View File
@@ -0,0 +1,103 @@
use std::{io, sync::Once};
use base64::Engine;
use warp_graphql::managed_secrets::ManagedSecretType;
use crate::secret_value::ManagedSecretValue;
mod hpke_impl;
static INIT: Once = Once::new();
/// Initialize cryptography providers for secret enveloping. This is safe to call multiple times.
pub fn init() {
INIT.call_once(|| {
tink_hybrid::init();
use hpke_impl::{HpkePrivateKeyManager, HpkePublicKeyManager};
use std::sync::Arc;
tink_core::registry::register_key_manager(Arc::new(HpkePublicKeyManager::new()))
.expect("unable to register HPKE public key manager");
tink_core::registry::register_key_manager(Arc::new(HpkePrivateKeyManager::new()))
.expect("unable to register HPKE private key manager");
});
}
#[derive(thiserror::Error, Debug)]
pub enum EnvelopeError {
#[error("invalid public key")]
InvalidPublicKey(#[source] anyhow::Error),
#[error("cryptography operation failed")]
Tink(#[from] tink_core::TinkError),
#[error("failed to marshal secret value")]
MarshalSecretValue(#[source] serde_json::Error),
}
/// UploadKey is the client-side (public) representation of the tink keysets used to encrypt
/// secrets before uploading to the server.
pub struct UploadKey {
#[allow(dead_code)]
public_key: tink_core::keyset::Handle,
encrypt: Box<dyn tink_core::HybridEncrypt>,
}
impl UploadKey {
pub fn import_public_keyset(public_key: &str) -> Result<Self, EnvelopeError> {
let key_bytes = base64::prelude::BASE64_STANDARD
.decode(public_key)
.map_err(|e| EnvelopeError::InvalidPublicKey(anyhow::anyhow!(e)))?;
let mut key_reader = tink_core::keyset::BinaryReader::new(io::Cursor::new(key_bytes));
let keyset = tink_core::keyset::Handle::read_with_no_secrets(&mut key_reader)?;
let primitive = tink_hybrid::new_encrypt(&keyset)?;
Ok(UploadKey {
public_key: keyset,
encrypt: primitive,
})
}
/// Encrypt a secret value for uploading to the server.
pub fn encrypt_secret(
&self,
actor_uid: &str,
secret_name: &str,
secret: &ManagedSecretValue,
) -> Result<String, EnvelopeError> {
let context = UploadContext {
actor_uid,
secret_name,
secret_type: secret.secret_type(),
};
let secret_plaintext =
serde_json::to_vec(secret).map_err(EnvelopeError::MarshalSecretValue)?;
let encrypted = self
.encrypt
.encrypt(&secret_plaintext, context.encode().as_bytes())?;
Ok(base64::prelude::BASE64_STANDARD.encode(encrypted))
}
}
struct UploadContext<'a> {
actor_uid: &'a str,
secret_name: &'a str,
secret_type: ManagedSecretType,
}
impl<'a> UploadContext<'a> {
fn encode(&self) -> String {
// This must match the context encoding format used by the server.
format!(
"1:{}:{}:{}",
self.actor_uid,
self.secret_name,
self.secret_type.envelope_name()
)
}
}
#[cfg(test)]
#[path = "envelope_tests.rs"]
mod tests;
@@ -0,0 +1,315 @@
//! HPKE key manager implementations using the pure-Rust [`hpke`] crate.
//!
//! Supports both encryption (public key manager) and decryption (private key manager).
use hpke::{
Deserializable, OpModeR, OpModeS, Serializable,
aead::{AesGcm128, AesGcm256, ChaCha20Poly1305},
kdf::HkdfSha256,
kem::X25519HkdfSha256,
};
use rand::{SeedableRng as _, rngs::StdRng};
use tink_core::TinkError;
use tink_proto::{HpkeAead, HpkeKdf, HpkeKem, prost::Message};
pub const HPKE_PUBLIC_KEY_TYPE_URL: &str = "type.googleapis.com/google.crypto.tink.HpkePublicKey";
pub const HPKE_PRIVATE_KEY_TYPE_URL: &str = "type.googleapis.com/google.crypto.tink.HpkePrivateKey";
const HPKE_PUBLIC_KEY_KEY_VERSION: u32 = 0;
const HPKE_PRIVATE_KEY_KEY_VERSION: u32 = 0;
// ── Cipher suite ────────────────────────────────────────────────────────────
/// Validated HPKE cipher suite. Adding a new combination requires adding a
/// variant here and handling it in both [`HpkeSuite::seal`] and
/// [`HpkeSuite::open`], so the compiler enforces exhaustive handling.
#[derive(Clone, Copy)]
enum HpkeSuite {
X25519Sha256Aes256Gcm,
X25519Sha256Aes128Gcm,
X25519Sha256Chacha20Poly1305,
}
impl HpkeSuite {
fn seal(
self,
public_key: &<X25519HkdfSha256 as hpke::Kem>::PublicKey,
context_info: &[u8],
plaintext: &[u8],
) -> Result<Vec<u8>, TinkError> {
let mut rng = StdRng::from_os_rng();
macro_rules! do_seal {
($aead_ty:ty) => {{
let (encapped_key, ciphertext) =
hpke::single_shot_seal::<$aead_ty, HkdfSha256, X25519HkdfSha256, _>(
&OpModeS::Base,
public_key,
context_info,
plaintext,
&[],
&mut rng,
)
.map_err(|e| TinkError::from(format!("HpkeSuite::seal failed: {e:?}")))?;
let mut output = encapped_key.to_bytes().to_vec();
output.extend_from_slice(&ciphertext);
Ok(output)
}};
}
match self {
HpkeSuite::X25519Sha256Aes256Gcm => do_seal!(AesGcm256),
HpkeSuite::X25519Sha256Aes128Gcm => do_seal!(AesGcm128),
HpkeSuite::X25519Sha256Chacha20Poly1305 => do_seal!(ChaCha20Poly1305),
}
}
fn open(
self,
private_key: &<X25519HkdfSha256 as hpke::Kem>::PrivateKey,
context_info: &[u8],
ciphertext: &[u8],
) -> Result<Vec<u8>, TinkError> {
let (enc_bytes, encrypted_data) = ciphertext
.split_at_checked(X25519_ENCAPPED_KEY_LEN)
.ok_or_else(|| TinkError::from("HpkeSuite::open: ciphertext too short"))?;
macro_rules! do_open {
($aead_ty:ty) => {{
let encapped_key =
<X25519HkdfSha256 as hpke::Kem>::EncappedKey::from_bytes(enc_bytes)
.map_err(|_| TinkError::new("HpkeSuite::open: invalid encapped key"))?;
hpke::single_shot_open::<$aead_ty, HkdfSha256, X25519HkdfSha256>(
&OpModeR::Base,
private_key,
&encapped_key,
context_info,
encrypted_data,
&[],
)
.map_err(|e| TinkError::from(format!("HpkeSuite::open failed: {e:?}")))
}};
}
match self {
HpkeSuite::X25519Sha256Aes256Gcm => do_open!(AesGcm256),
HpkeSuite::X25519Sha256Aes128Gcm => do_open!(AesGcm128),
HpkeSuite::X25519Sha256Chacha20Poly1305 => do_open!(ChaCha20Poly1305),
}
}
}
/// The X25519 encapsulated key is always 32 bytes.
const X25519_ENCAPPED_KEY_LEN: usize = 32;
// ── Public key manager (encryption) ─────────────────────────────────────────
pub(crate) struct HpkePublicKeyManager;
impl HpkePublicKeyManager {
pub fn new() -> Self {
Self
}
}
impl tink_core::registry::KeyManager for HpkePublicKeyManager {
fn primitive(&self, serialized_key: &[u8]) -> Result<tink_core::Primitive, TinkError> {
if serialized_key.is_empty() {
return Err(TinkError::new("HpkePublicKeyManager: invalid key"));
}
let key = tink_proto::HpkePublicKey::decode(serialized_key).map_err(|e| {
TinkError::from(format!("HpkePublicKeyManager: invalid public key: {e:#}"))
})?;
let suite = validate_public_key(&key)?;
let pk = <X25519HkdfSha256 as hpke::Kem>::PublicKey::from_bytes(&key.public_key).map_err(
|_| TinkError::new("HpkePublicKeyManager: failed to deserialize public key"),
)?;
Ok(tink_core::Primitive::HybridEncrypt(Box::new(
HpkeHybridEncrypt {
public_key: pk,
suite,
},
)))
}
fn new_key(&self, _serialized_key_format: &[u8]) -> Result<Vec<u8>, TinkError> {
Err(TinkError::new(
"HpkePublicKeyManager: new_key not implemented",
))
}
fn type_url(&self) -> &'static str {
HPKE_PUBLIC_KEY_TYPE_URL
}
fn key_material_type(&self) -> tink_proto::key_data::KeyMaterialType {
tink_proto::key_data::KeyMaterialType::AsymmetricPublic
}
}
#[derive(Clone)]
struct HpkeHybridEncrypt {
public_key: <X25519HkdfSha256 as hpke::Kem>::PublicKey,
suite: HpkeSuite,
}
impl tink_core::HybridEncrypt for HpkeHybridEncrypt {
fn encrypt(&self, plaintext: &[u8], context_info: &[u8]) -> Result<Vec<u8>, TinkError> {
self.suite.seal(&self.public_key, context_info, plaintext)
}
}
// ── Private key manager (decryption) ────────────────────────────────────────
pub(crate) struct HpkePrivateKeyManager;
impl HpkePrivateKeyManager {
pub fn new() -> Self {
Self
}
}
impl tink_core::registry::KeyManager for HpkePrivateKeyManager {
fn primitive(&self, serialized_key: &[u8]) -> Result<tink_core::Primitive, TinkError> {
if serialized_key.is_empty() {
return Err(TinkError::new("HpkePrivateKeyManager: invalid key"));
}
let key = tink_proto::HpkePrivateKey::decode(serialized_key).map_err(|e| {
TinkError::from(format!("HpkePrivateKeyManager: invalid private key: {e:#}"))
})?;
let suite = validate_private_key(&key)?;
let sk = <X25519HkdfSha256 as hpke::Kem>::PrivateKey::from_bytes(&key.private_key)
.map_err(|_| {
TinkError::new("HpkePrivateKeyManager: failed to deserialize private key")
})?;
Ok(tink_core::Primitive::HybridDecrypt(Box::new(
HpkeHybridDecrypt {
private_key: sk,
suite,
},
)))
}
fn new_key(&self, _serialized_key_format: &[u8]) -> Result<Vec<u8>, TinkError> {
Err(TinkError::new(
"HpkePrivateKeyManager: new_key not implemented",
))
}
fn type_url(&self) -> &'static str {
HPKE_PRIVATE_KEY_TYPE_URL
}
fn key_material_type(&self) -> tink_proto::key_data::KeyMaterialType {
tink_proto::key_data::KeyMaterialType::AsymmetricPrivate
}
fn supports_private_keys(&self) -> bool {
true
}
fn public_key_data(
&self,
serialized_priv_key: &[u8],
) -> Result<tink_proto::KeyData, TinkError> {
let priv_key = tink_proto::HpkePrivateKey::decode(serialized_priv_key).map_err(|e| {
TinkError::from(format!("HpkePrivateKeyManager: invalid private key: {e:#}"))
})?;
let mut serialized_pub_key = Vec::new();
priv_key
.public_key
.ok_or_else(|| TinkError::new("HpkePrivateKeyManager: no public key"))?
.encode(&mut serialized_pub_key)
.map_err(|e| {
TinkError::from(format!("HpkePrivateKeyManager: invalid public key: {e:#}"))
})?;
Ok(tink_proto::KeyData {
type_url: HPKE_PUBLIC_KEY_TYPE_URL.to_string(),
value: serialized_pub_key,
key_material_type: tink_proto::key_data::KeyMaterialType::AsymmetricPublic.into(),
})
}
}
/// The `x25519-dalek` `StaticSecret` type already implements `Zeroize`, so the
/// private key bytes are securely cleared when this struct is dropped.
#[derive(Clone)]
struct HpkeHybridDecrypt {
private_key: <X25519HkdfSha256 as hpke::Kem>::PrivateKey,
suite: HpkeSuite,
}
impl tink_core::HybridDecrypt for HpkeHybridDecrypt {
fn decrypt(&self, ciphertext: &[u8], context_info: &[u8]) -> Result<Vec<u8>, TinkError> {
self.suite.open(&self.private_key, context_info, ciphertext)
}
}
// ── Validation helpers ──────────────────────────────────────────────────────
fn validate_public_key(key: &tink_proto::HpkePublicKey) -> Result<HpkeSuite, TinkError> {
tink_core::keyset::validate_key_version(key.version, HPKE_PUBLIC_KEY_KEY_VERSION)?;
let params = key
.params
.as_ref()
.ok_or_else(|| TinkError::new("no params"))?;
validate_key_params(params)
}
fn validate_private_key(key: &tink_proto::HpkePrivateKey) -> Result<HpkeSuite, TinkError> {
tink_core::keyset::validate_key_version(key.version, HPKE_PRIVATE_KEY_KEY_VERSION)?;
let pub_key = key
.public_key
.as_ref()
.ok_or_else(|| TinkError::new("no public key"))?;
tink_core::keyset::validate_key_version(pub_key.version, HPKE_PUBLIC_KEY_KEY_VERSION)?;
let params = pub_key
.params
.as_ref()
.ok_or_else(|| TinkError::new("no params"))?;
validate_key_params(params)
}
/// Validate HPKE parameters and return the resolved [`HpkeSuite`].
///
/// Adding a new supported suite requires adding a variant to [`HpkeSuite`] and
/// handling it in both `seal` and `open`, so the compiler enforces completeness.
fn validate_key_params(params: &tink_proto::HpkeParams) -> Result<HpkeSuite, TinkError> {
let kem = match HpkeKem::try_from(params.kem) {
Ok(HpkeKem::DhkemX25519HkdfSha256) => HpkeKem::DhkemX25519HkdfSha256,
Ok(HpkeKem::KemUnknown) => return Err(TinkError::new("unknown KEM")),
Err(_) => return Err(TinkError::new("unrecognized KEM value")),
};
let kdf = match HpkeKdf::try_from(params.kdf) {
Ok(HpkeKdf::HkdfSha256) => HpkeKdf::HkdfSha256,
Ok(HpkeKdf::KdfUnknown) => return Err(TinkError::new("unknown KDF")),
Err(_) => return Err(TinkError::new("unrecognized KDF value")),
};
let aead = match HpkeAead::try_from(params.aead) {
Ok(HpkeAead::Aes256Gcm) => HpkeAead::Aes256Gcm,
Ok(HpkeAead::Aes128Gcm) => HpkeAead::Aes128Gcm,
Ok(HpkeAead::Chacha20Poly1305) => HpkeAead::Chacha20Poly1305,
Ok(HpkeAead::AeadUnknown) => return Err(TinkError::new("unknown AEAD")),
Err(_) => return Err(TinkError::new("unrecognized AEAD value")),
};
match (kem, kdf, aead) {
(HpkeKem::DhkemX25519HkdfSha256, HpkeKdf::HkdfSha256, HpkeAead::Aes256Gcm) => {
Ok(HpkeSuite::X25519Sha256Aes256Gcm)
}
(HpkeKem::DhkemX25519HkdfSha256, HpkeKdf::HkdfSha256, HpkeAead::Aes128Gcm) => {
Ok(HpkeSuite::X25519Sha256Aes128Gcm)
}
(HpkeKem::DhkemX25519HkdfSha256, HpkeKdf::HkdfSha256, HpkeAead::Chacha20Poly1305) => {
Ok(HpkeSuite::X25519Sha256Chacha20Poly1305)
}
_ => Err(TinkError::new("unsupported HPKE suite combination")),
}
}
@@ -0,0 +1,140 @@
use std::io;
use tink_proto::{KeysetInfo, keyset_info::KeyInfo};
use crate::ManagedSecretValue;
use super::UploadKey;
#[test]
fn test_import_public_keyset() {
super::init();
// A base64-encoded public keyset as returned by warp-server.
let public_key = "COvInMIBEnAKZAo0dHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuSHBrZVB1YmxpY0tleRIqEgYIARABGAIaIHRaibhtYbpEfh2CSpdDPhh/6lCBnfoO3nqBmZ3VQGJyGAMQARjryJzCASAB";
let upload_key =
UploadKey::import_public_keyset(public_key).expect("unable to import public keyset");
let keyset_info = upload_key.public_key.keyset_info();
assert_eq!(
keyset_info,
KeysetInfo {
primary_key_id: 407315563,
key_info: vec![KeyInfo {
key_id: 407315563,
status: tink_proto::KeyStatusType::Enabled.into(),
type_url: "type.googleapis.com/google.crypto.tink.HpkePublicKey".to_string(),
output_prefix_type: tink_proto::OutputPrefixType::Tink.into(),
}],
}
);
let encrypted = upload_key
.encrypt
.encrypt(b"hello from rust", b"rust context")
.expect("unable to encrypt");
assert!(!encrypted.is_empty());
}
/// An HPKE private key for use in tests.
///
/// Created with:
/// ```sh
/// $ java -jar /opt/homebrew/Cellar/tinkey/1.12.0/bin/tinkey_deploy.jar create-keyset --key-template DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_AES_256_GCM --out-format json | jq .
/// ```
const TEST_PRIVATE_KEY: &str = r#"
{
"primaryKeyId": 625520774,
"key": [
{
"keyData": {
"typeUrl": "type.googleapis.com/google.crypto.tink.HpkePrivateKey",
"value": "EioSBggBEAEYAhogGHVh0Tju/DHOWEgpuUJ+9P/pXa5tK16udRWoJJwHbnIaIHdq5FthS7H4Q6xSLzCEnbf1z/F1+PTQHev/5PJ+pc+m",
"keyMaterialType": "ASYMMETRIC_PRIVATE"
},
"status": "ENABLED",
"keyId": 625520774,
"outputPrefixType": "TINK"
}
]
}
"#;
/// An HPKE public key for use in tests, corresponding to [`TEST_PRIVATE_KEY`].
///
/// Created with:
/// ```sh
/// $ java -jar /opt/homebrew/Cellar/tinkey/1.12.0/bin/tinkey_deploy.jar create-public-keyset | jq .
/// < private key JSON on stdin >
/// ```
const TEST_PUBLIC_KEY: &str = r#"
{
"primaryKeyId": 625520774,
"key": [
{
"keyData": {
"typeUrl": "type.googleapis.com/google.crypto.tink.HpkePublicKey",
"value": "EgYIARABGAIaIBh1YdE47vwxzlhIKblCfvT/6V2ubSternUVqCScB25y",
"keyMaterialType": "ASYMMETRIC_PUBLIC"
},
"status": "ENABLED",
"keyId": 625520774,
"outputPrefixType": "TINK"
}
]
}
"#;
/// Test encrypting a managed secret value.
#[test]
fn test_encrypt_managed_secret() {
super::init();
let keyset = read_keyset_json(TEST_PUBLIC_KEY);
let upload_key = UploadKey {
encrypt: tink_hybrid::new_encrypt(&keyset).expect("failed to create encrypt primitive"),
public_key: keyset,
};
let encrypted = upload_key
.encrypt_secret(
"user123",
"MY_SECRET",
&ManagedSecretValue::RawValue {
value: "secret".to_string(),
},
)
.expect("failed to encrypt secret");
assert!(!encrypted.is_empty());
}
/// Test our HPKE encryption and decryption primitives. At the very least, they should be able to roundtrip a plaintext value.
#[test]
fn test_encrypt_decrypt() {
super::init();
let private_key = read_keyset_json(TEST_PRIVATE_KEY);
let public_key = read_keyset_json(TEST_PUBLIC_KEY);
let encrypt =
tink_hybrid::new_encrypt(&public_key).expect("failed to create encrypt primitive");
let decrypt =
tink_hybrid::new_decrypt(&private_key).expect("failed to create decrypt primitive");
let context = b"I am context";
let plaintext = b"hello from rust";
let encrypted = encrypt
.encrypt(plaintext, context)
.expect("failed to encrypt");
let decrypted = decrypt
.decrypt(&encrypted, context)
.expect("failed to decrypt");
assert_eq!(decrypted, plaintext);
}
fn read_keyset_json(json: &str) -> tink_core::keyset::Handle {
let mut reader = tink_core::keyset::JsonReader::new(io::Cursor::new(json.as_bytes()));
tink_core::keyset::insecure::read(&mut reader).expect("failed to read keyset")
}
+286
View File
@@ -0,0 +1,286 @@
use std::{
collections::HashMap,
ffi::OsString,
io::Write,
path::{Path, PathBuf},
time::Duration,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tempfile::NamedTempFile;
use warp_core::safe_debug;
use crate::client::TaskIdentityToken;
const GCP_WORKLOAD_IDENTITY_FEDERATION_VERSION: u8 = 1;
pub(crate) const TOKEN_TYPE_ID_TOKEN: &str = "urn:ietf:params:oauth:token-type:id_token";
pub(crate) const TOKEN_TYPE_JWT: &str = "urn:ietf:params:oauth:token-type:jwt";
/// Configuration for GCP Workload Identity Federation.
///
/// These fields map directly to the GCP concepts required to set up
/// executable-sourced external credentials
/// ([AIP-4117](https://google.aip.dev/auth/4117)).
#[derive(Debug, Clone)]
pub struct GcpFederationConfig {
/// GCP project number (not project ID).
pub project_number: String,
/// Workload identity pool ID.
pub pool_id: String,
/// Workload identity pool provider ID.
pub provider_id: String,
/// Service account email for impersonation. When set, the federated token
/// is exchanged for a service account access token.
pub service_account_email: Option<String>,
/// Lifetime for the impersonated service account token.
pub token_lifetime: Option<Duration>,
}
/// Handle for GCP Workload Identity Federation credentials.
///
/// The handle represents a GCP authentication config file that uses Workload
/// Identity Federation to authenticate to GCP as a particular Oz agent run.
///
/// When the handle is dropped, the backing temporary files are deleted.
pub struct GcpCredentials {
/// Temporary file holding the GCP credentials configuration file.
config_file: NamedTempFile,
/// Temporary file where Warp OIDC tokens are cached.
output_file: NamedTempFile,
}
impl GcpCredentials {
/// Create executable-sourced GCP federation credentials for the given task.
///
/// This writes an [AIP-4117](https://google.aip.dev/auth/4117)
/// `external_account` config to a temporary file and returns the
/// environment variables required for ADC to discover it.
pub fn federated(
task_id: &str,
config: &GcpFederationConfig,
) -> Result<Self, PrepareGcpCredentialsError> {
safe_debug!(
safe: ("Configuring GCP workload identity federation"),
full: ("Configuring GCP workload identity federation for project={}, pool={}, provider={}", config.project_number, config.pool_id, config.provider_id)
);
let oz_binary_path =
std::env::current_exe().map_err(|_| PrepareGcpCredentialsError::NoBinaryPath)?;
// Create the output file that the executable will write cached tokens to.
let output_file = NamedTempFile::new()
.map_err(|source| PrepareGcpCredentialsError::FileCreate { source })?;
let cred_config_json =
generate_gcp_credential_config(task_id, config, &oz_binary_path, output_file.path())?;
safe_debug!(
safe: ("Generated federated GCP configuration"),
full: ("Generated federated GCP configuration: {cred_config_json:#}")
);
let json_bytes = serde_json::to_vec_pretty(&cred_config_json)
.map_err(PrepareGcpCredentialsError::SerializeConfig)?;
let mut config_file = NamedTempFile::new()
.map_err(|source| PrepareGcpCredentialsError::FileCreate { source })?;
config_file.write_all(&json_bytes).map_err(|source| {
PrepareGcpCredentialsError::FileWrite {
path: config_file.path().to_path_buf(),
source,
}
})?;
safe_debug!(
safe: ("Wrote GCP credentials config file"),
full: ("Wrote GCP credentials to {}", config_file.path().display())
);
Ok(Self {
config_file,
output_file,
})
}
/// Environment variables to set in a session in order to use this GCP
/// configuration.
pub fn env_vars(&self) -> HashMap<OsString, OsString> {
let config_file_path = self.config_file.path().as_os_str();
let mut vars = HashMap::with_capacity(3);
vars.insert(
OsString::from("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"),
OsString::from("1"),
);
// Google Cloud SDKs use the `GOOGLE_APPLICATION_CREDENTIALS` variable.
vars.insert(
OsString::from("GOOGLE_APPLICATION_CREDENTIALS"),
config_file_path.to_owned(),
);
// The `gcloud` CLI has its own auth system, but accepts credential file overrides.
vars.insert(
OsString::from("CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE"),
config_file_path.to_owned(),
);
vars
}
/// Clean up the GCP credential state. This will remove the temporary configuration and
/// token files.
pub fn cleanup(self) -> std::io::Result<()> {
self.config_file.close()?;
self.output_file.close()?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum PrepareGcpCredentialsError {
#[error("Could not determine the current executable path")]
NoBinaryPath,
#[error("Cannot use executable {} for GCP executable-sourced credentials", path.display())]
InvalidBinaryPath { path: PathBuf },
#[error("Cannot use run {task_id} for GCP executable-sourced credentials")]
InvalidTaskId { task_id: String },
#[error("Failed to create credential config file: {source}")]
FileCreate {
#[source]
source: std::io::Error,
},
#[error("Failed to serialize credential config: {0}")]
SerializeConfig(#[source] serde_json::Error),
#[error("Failed to write credential config to {path}: {source}")]
FileWrite {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
/// Successful output payload for GCP executable-sourced credentials.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GcpWorkloadIdentityFederationToken {
pub version: u8,
pub success: bool,
pub token_type: String,
pub id_token: String,
pub expiration_time: i64,
}
impl GcpWorkloadIdentityFederationToken {
pub(crate) fn new(token: TaskIdentityToken, token_type: String) -> Self {
Self {
version: GCP_WORKLOAD_IDENTITY_FEDERATION_VERSION,
success: true,
token_type,
id_token: token.token,
expiration_time: token.expires_at.timestamp(),
}
}
}
/// Error output payload for GCP executable-sourced credentials.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GcpWorkloadIdentityFederationError {
pub version: u8,
pub success: bool,
pub code: String,
pub message: String,
}
impl GcpWorkloadIdentityFederationError {
pub(crate) fn new(message: impl Into<String>) -> Self {
Self {
version: GCP_WORKLOAD_IDENTITY_FEDERATION_VERSION,
success: false,
code: "TOKEN_ISSUANCE_FAILED".into(),
message: message.into(),
}
}
}
pub(crate) fn gcp_workload_identity_federation_audience(
project_number: &str,
pool_id: &str,
provider_id: &str,
) -> String {
format!(
"//iam.googleapis.com/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/providers/{provider_id}"
)
}
/// Produce the [AIP-4117](https://google.aip.dev/auth/4117) `external_account`
/// credential configuration JSON.
///
/// The returned value can be written to
/// `$HOME/.config/gcloud/application_default_credentials.json` so that any GCP
/// SDK picks it up automatically.
///
/// `oz_binary_path` should be the absolute path to the current `oz` executable.
fn generate_gcp_credential_config(
task_id: &str,
config: &GcpFederationConfig,
oz_binary_path: &Path,
output_file: &Path,
) -> Result<Value, PrepareGcpCredentialsError> {
let audience = gcp_workload_identity_federation_audience(
&config.project_number,
&config.pool_id,
&config.provider_id,
);
let oz_binary_display = oz_binary_path.display().to_string();
// The executable command is embedded as a single string in the credential
// config. GCP SDKs split it with `strings.Fields` (Go) or `shlex.split`
// (Python), so whitespace in either the binary path or the task ID would
// cause the command to be misparsed.
if oz_binary_display.contains(' ') {
return Err(PrepareGcpCredentialsError::InvalidBinaryPath {
path: oz_binary_path.to_path_buf(),
});
}
if task_id.contains(' ') {
return Err(PrepareGcpCredentialsError::InvalidTaskId {
task_id: task_id.to_owned(),
});
}
let mut command = format!("{oz_binary_display} federate issue-gcp-token --run-id {task_id}");
if let Some(lifetime) = config.token_lifetime {
command.push_str(&format!(" --duration {}s", lifetime.as_secs()));
}
let mut cred_config = json!({
"type": "external_account",
"audience": audience,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
// Regional STS endpoints with workload identity federation are pre-GA and we do not yet
// support them:
// https://docs.cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation#sts-regional-endpoints
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": {
"executable": {
"command": command,
"timeout_millis": 30000,
"output_file": output_file.display().to_string()
}
}
});
if let Some(email) = &config.service_account_email {
let impersonation_url = format!(
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{email}:generateAccessToken"
);
cred_config["service_account_impersonation_url"] = json!(impersonation_url);
if let Some(lifetime) = config.token_lifetime {
cred_config["service_account_impersonation"] = json!({
"token_lifetime_seconds": lifetime.as_secs()
});
}
}
Ok(cred_config)
}
#[cfg(test)]
#[path = "gcp_tests.rs"]
mod tests;
+153
View File
@@ -0,0 +1,153 @@
use std::path::Path;
use std::time::Duration;
use serde_json::json;
use super::{GcpFederationConfig, PrepareGcpCredentialsError, generate_gcp_credential_config};
#[test]
fn basic_config_shape() {
let config = GcpFederationConfig {
project_number: "123456789".to_string(),
pool_id: "my-pool".to_string(),
provider_id: "my-provider".to_string(),
service_account_email: None,
token_lifetime: None,
};
let result = generate_gcp_credential_config(
"task-42",
&config,
Path::new("/usr/bin/oz"),
Path::new("/tmp/token_cache"),
)
.unwrap();
assert_eq!(
result,
json!({
"type": "external_account",
"audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider",
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": {
"executable": {
"command": "/usr/bin/oz federate issue-gcp-token --run-id task-42",
"timeout_millis": 30000,
"output_file": "/tmp/token_cache"
}
}
})
);
}
#[test]
fn rejects_binary_path_with_spaces() {
let config = GcpFederationConfig {
project_number: "123".to_string(),
pool_id: "pool".to_string(),
provider_id: "prov".to_string(),
service_account_email: None,
token_lifetime: None,
};
let result = generate_gcp_credential_config(
"task-1",
&config,
Path::new("/path with spaces/oz"),
Path::new("/tmp/out"),
);
assert!(matches!(
result,
Err(PrepareGcpCredentialsError::InvalidBinaryPath { .. })
));
}
#[test]
fn rejects_task_id_with_spaces() {
let config = GcpFederationConfig {
project_number: "123".to_string(),
pool_id: "pool".to_string(),
provider_id: "prov".to_string(),
service_account_email: None,
token_lifetime: None,
};
let result = generate_gcp_credential_config(
"task with spaces",
&config,
Path::new("/bin/oz"),
Path::new("/tmp/out"),
);
assert!(matches!(
result,
Err(PrepareGcpCredentialsError::InvalidTaskId { .. })
));
}
#[test]
fn service_account_impersonation() {
let config = GcpFederationConfig {
project_number: "111".to_string(),
pool_id: "pool".to_string(),
provider_id: "prov".to_string(),
service_account_email: Some("sa@project.iam.gserviceaccount.com".to_string()),
token_lifetime: Some(Duration::from_secs(1800)),
};
let result =
generate_gcp_credential_config("t-1", &config, Path::new("/bin/oz"), Path::new("/tmp/out"))
.unwrap();
assert_eq!(
result,
json!({
"type": "external_account",
"audience": "//iam.googleapis.com/projects/111/locations/global/workloadIdentityPools/pool/providers/prov",
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": {
"executable": {
"command": "/bin/oz federate issue-gcp-token --run-id t-1 --duration 1800s",
"timeout_millis": 30000,
"output_file": "/tmp/out"
}
},
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com:generateAccessToken",
"service_account_impersonation": {
"token_lifetime_seconds": 1800
}
})
);
}
#[test]
fn no_duration_flag_when_lifetime_absent() {
let config = GcpFederationConfig {
project_number: "333".to_string(),
pool_id: "pool".to_string(),
provider_id: "prov".to_string(),
service_account_email: None,
token_lifetime: None,
};
let result =
generate_gcp_credential_config("t-3", &config, Path::new("/bin/oz"), Path::new("/tmp/out"))
.unwrap();
assert_eq!(
result,
json!({
"type": "external_account",
"audience": "//iam.googleapis.com/projects/333/locations/global/workloadIdentityPools/pool/providers/prov",
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": {
"executable": {
"command": "/bin/oz federate issue-gcp-token --run-id t-3",
"timeout_millis": 30000,
"output_file": "/tmp/out"
}
}
})
);
}
+14
View File
@@ -0,0 +1,14 @@
pub mod client;
mod envelope;
mod gcp;
mod manager;
mod secret_value;
pub use client::TaskIdentityToken;
pub use envelope::{UploadKey, init as init_envelope};
pub use gcp::{
GcpCredentials, GcpFederationConfig, GcpWorkloadIdentityFederationError,
GcpWorkloadIdentityFederationToken, PrepareGcpCredentialsError,
};
pub use manager::{ActorProvider, ManagedSecretManager};
pub use secret_value::ManagedSecretValue;
+280
View File
@@ -0,0 +1,280 @@
use std::{collections::HashMap, future::Future, sync::Arc, time::Duration};
use vec1::vec1;
use warp_core::features::FeatureFlag;
use warp_graphql::managed_secrets::ManagedSecret;
use warpui::{Entity, SingletonEntity};
use crate::{
ManagedSecretValue,
client::{
IdentityTokenOptions, ManagedSecretConfigs, ManagedSecretsClient, SecretOwner,
TaskIdentityToken,
},
envelope::UploadKey,
gcp::{self, GcpWorkloadIdentityFederationError, GcpWorkloadIdentityFederationToken},
};
use warp_graphql::queries::task_secrets::ManagedSecretValue as GqlManagedSecretValue;
/// Singleton model for working with Warp-managed secrets.
pub struct ManagedSecretManager {
client: Arc<dyn ManagedSecretsClient>,
actor_provider: Arc<dyn ActorProvider>,
}
pub trait ActorProvider: Send + Sync + 'static {
fn actor_uid(&self) -> Option<String>;
}
impl ManagedSecretManager {
pub fn new(
client: Arc<dyn ManagedSecretsClient>,
actor_provider: Arc<dyn ActorProvider>,
) -> Self {
crate::envelope::init();
Self {
client,
actor_provider,
}
}
pub fn create_secret(
&self,
owner: SecretOwner,
name: String,
value: ManagedSecretValue,
description: Option<String>,
) -> impl Future<Output = anyhow::Result<ManagedSecret>> + use<> {
let client = self.client.clone();
let actor_provider = self.actor_provider.clone();
async move {
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
return Err(anyhow::anyhow!("This feature is not enabled"));
}
// We retrieve all upload keys on demand. These should potentially be fetched and stored
// ahead of time instead.
let configs = client.get_managed_secret_configs().await?;
let Some(actor) = actor_provider.actor_uid() else {
return Err(anyhow::anyhow!("No authenticated user"));
};
// Chain errors so that we don't hold an `UploadKey` handle across an `.await`.
let encrypted_value = owner_public_key(&configs, &owner)
.and_then(|public_key| {
UploadKey::import_public_keyset(public_key).map_err(anyhow::Error::from)
})
.and_then(|public_key| {
public_key
.encrypt_secret(&actor, &name, &value)
.map_err(anyhow::Error::from)
})?;
let managed_secret = client
.create_managed_secret(
owner,
name,
value.secret_type(),
encrypted_value,
description,
)
.await?;
Ok(managed_secret)
}
}
pub fn delete_secret(
&self,
owner: SecretOwner,
name: String,
) -> impl Future<Output = anyhow::Result<()>> + use<> {
let client = self.client.clone();
async move {
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
return Err(anyhow::anyhow!("This feature is not enabled"));
}
client.delete_managed_secret(owner, name).await?;
Ok(())
}
}
pub fn update_secret(
&self,
owner: SecretOwner,
name: String,
value: Option<ManagedSecretValue>,
description: Option<String>,
) -> impl Future<Output = anyhow::Result<ManagedSecret>> + use<> {
let client = self.client.clone();
let actor_provider = self.actor_provider.clone();
async move {
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
return Err(anyhow::anyhow!("This feature is not enabled"));
}
let encrypted_value = if let Some(value) = value {
// We retrieve all upload keys on demand. These should potentially be fetched and stored
// ahead of time instead.
let configs = client.get_managed_secret_configs().await?;
let Some(actor) = actor_provider.actor_uid() else {
return Err(anyhow::anyhow!("No authenticated user"));
};
// Chain errors so that we don't hold an `UploadKey` handle across an `.await`.
let encrypted = owner_public_key(&configs, &owner)
.and_then(|public_key| {
UploadKey::import_public_keyset(public_key).map_err(anyhow::Error::from)
})
.and_then(|public_key| {
public_key
.encrypt_secret(&actor, &name, &value)
.map_err(anyhow::Error::from)
})?;
Some(encrypted)
} else {
None
};
let managed_secret = client
.update_managed_secret(owner, name, encrypted_value, description)
.await?;
Ok(managed_secret)
}
}
/// List all managed secrets accessible to the current user.
pub fn list_secrets(&self) -> impl Future<Output = anyhow::Result<Vec<ManagedSecret>>> + use<> {
let client = self.client.clone();
async move {
let secrets = client.list_secrets().await?;
Ok(secrets)
}
}
/// Get Warp-managed secrets scoped to the currently-executing task.
///
/// This will fail if not in an ambient agent.
pub fn get_task_secrets(
&self,
task_id: String,
) -> impl Future<Output = anyhow::Result<HashMap<String, ManagedSecretValue>>> + use<> {
let client = self.client.clone();
async move {
// We only need the workload token for the duration of the request.
let workload_token =
warp_isolation_platform::issue_workload_token(Some(Duration::from_mins(5))).await?;
let gql_secrets = client
.get_task_secrets(task_id, workload_token.token)
.await?;
// Convert GQL ManagedSecretValue to our ManagedSecretValue
let mut secrets = HashMap::new();
for (name, gql_value) in gql_secrets {
let value = match gql_value {
GqlManagedSecretValue::ManagedSecretRawValue(raw) => {
ManagedSecretValue::raw_value(raw.value)
}
GqlManagedSecretValue::ManagedSecretAnthropicApiKeyValue(v) => {
ManagedSecretValue::anthropic_api_key(v.api_key)
}
GqlManagedSecretValue::ManagedSecretAnthropicBedrockAccessKeyValue(v) => {
ManagedSecretValue::anthropic_bedrock_access_key(
v.aws_access_key_id,
v.aws_secret_access_key,
// aws_session_token is now optional on the server.
v.aws_session_token,
v.aws_region,
)
}
GqlManagedSecretValue::ManagedSecretAnthropicBedrockApiKeyValue(v) => {
ManagedSecretValue::anthropic_bedrock_api_key(
v.aws_bearer_token_bedrock,
v.aws_region,
)
}
GqlManagedSecretValue::Unknown => {
return Err(anyhow::anyhow!(
"Unknown secret value type for secret: {}",
name
));
}
};
secrets.insert(name, value);
}
Ok(secrets)
}
}
/// Issue a short-lived OIDC identity token for the current task.
pub fn issue_task_identity_token(
&self,
options: IdentityTokenOptions,
) -> impl Future<Output = anyhow::Result<TaskIdentityToken>> + use<> {
let client = self.client.clone();
async move { client.issue_task_identity_token(options).await }
}
/// Issue a short-lived OIDC identity token in the JSON shape expected by
/// GCP executable-sourced Workload Identity Federation credentials.
pub fn issue_gcp_workload_identity_federation_token(
&self,
audience: String,
token_type: String,
requested_duration: Duration,
) -> impl Future<
Output = Result<GcpWorkloadIdentityFederationToken, GcpWorkloadIdentityFederationError>,
> + use<> {
let client = self.client.clone();
async move {
match token_type.as_str() {
gcp::TOKEN_TYPE_ID_TOKEN | gcp::TOKEN_TYPE_JWT => (),
other => {
return Err(GcpWorkloadIdentityFederationError::new(format!(
"Unsupported token type `{other}`"
)));
}
}
match client
.issue_task_identity_token(IdentityTokenOptions {
audience,
requested_duration,
subject_template: vec1!["principal".to_owned()],
})
.await
{
Ok(token) => Ok(GcpWorkloadIdentityFederationToken::new(token, token_type)),
Err(err) => Err(GcpWorkloadIdentityFederationError::new(err.to_string())),
}
}
}
}
/// Find the public upload key corresponding to `owner`.
/// Returns an error if there's no such key in `configs`.
fn owner_public_key<'a>(
configs: &'a ManagedSecretConfigs,
owner: &SecretOwner,
) -> Result<&'a str, anyhow::Error> {
match owner {
SecretOwner::CurrentUser => configs
.user_secrets
.as_ref()
.and_then(|config| config.public_key.as_deref())
.ok_or_else(|| anyhow::anyhow!("No public key for user")),
SecretOwner::Team { team_uid } => configs
.team_secrets
.get(team_uid)
.and_then(|config| config.public_key.as_deref())
.ok_or_else(|| anyhow::anyhow!("No public key for team {team_uid}")),
}
}
impl Entity for ManagedSecretManager {
type Event = ();
}
impl SingletonEntity for ManagedSecretManager {}
+101
View File
@@ -0,0 +1,101 @@
use std::fmt;
use serde::Serialize;
use warp_graphql::managed_secrets::ManagedSecretType;
#[derive(Serialize)]
#[serde(untagged)]
pub enum ManagedSecretValue {
RawValue {
value: String,
},
AnthropicApiKey {
api_key: String,
},
AnthropicBedrockAccessKey {
aws_access_key_id: String,
aws_secret_access_key: String,
/// Optional AWS session token. Only required for temporary/STS credentials;
/// persistent IAM access keys do not need one. When `None`, the field is
/// omitted from the serialized JSON payload sent to the server.
#[serde(skip_serializing_if = "Option::is_none")]
aws_session_token: Option<String>,
aws_region: String,
},
AnthropicBedrockApiKey {
aws_bearer_token_bedrock: String,
aws_region: String,
},
}
impl ManagedSecretValue {
pub fn raw_value(s: impl Into<String>) -> Self {
Self::RawValue { value: s.into() }
}
pub fn anthropic_api_key(s: impl Into<String>) -> Self {
Self::AnthropicApiKey { api_key: s.into() }
}
/// Construct an Anthropic Bedrock access key secret from IAM credentials and AWS region.
///
/// `session_token` is optional and may be `None` for persistent IAM credentials
/// that do not require a session token.
pub fn anthropic_bedrock_access_key(
access_key_id: impl Into<String>,
secret_access_key: impl Into<String>,
session_token: Option<String>,
region: impl Into<String>,
) -> Self {
Self::AnthropicBedrockAccessKey {
aws_access_key_id: access_key_id.into(),
aws_secret_access_key: secret_access_key.into(),
aws_session_token: session_token,
aws_region: region.into(),
}
}
/// Construct an Anthropic Bedrock API key secret from a bearer token and AWS region.
pub fn anthropic_bedrock_api_key(token: impl Into<String>, region: impl Into<String>) -> Self {
Self::AnthropicBedrockApiKey {
aws_bearer_token_bedrock: token.into(),
aws_region: region.into(),
}
}
pub fn secret_type(&self) -> ManagedSecretType {
match self {
ManagedSecretValue::RawValue { .. } => ManagedSecretType::RawValue,
ManagedSecretValue::AnthropicApiKey { .. } => ManagedSecretType::AnthropicApiKey,
ManagedSecretValue::AnthropicBedrockAccessKey { .. } => {
ManagedSecretType::AnthropicBedrockAccessKey
}
ManagedSecretValue::AnthropicBedrockApiKey { .. } => {
ManagedSecretType::AnthropicBedrockApiKey
}
}
}
}
impl fmt::Debug for ManagedSecretValue {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ManagedSecretValue::RawValue { .. } => f
.debug_struct("ManagedSecret::RawValue")
.finish_non_exhaustive(),
ManagedSecretValue::AnthropicApiKey { .. } => f
.debug_struct("ManagedSecret::AnthropicApiKey")
.finish_non_exhaustive(),
ManagedSecretValue::AnthropicBedrockAccessKey { .. } => f
.debug_struct("ManagedSecret::AnthropicBedrockAccessKey")
.finish_non_exhaustive(),
ManagedSecretValue::AnthropicBedrockApiKey { .. } => f
.debug_struct("ManagedSecret::AnthropicBedrockApiKey")
.finish_non_exhaustive(),
}
}
}
#[cfg(test)]
#[path = "secret_value_tests.rs"]
mod tests;
@@ -0,0 +1,171 @@
use crate::secret_value::ManagedSecretValue;
/// Test to ensure that `raw_value` secrets are serialized in the format that the server expects.
#[test]
fn test_serialize_raw_value() {
let secret = ManagedSecretValue::RawValue {
value: "secret".to_string(),
};
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
assert_eq!(serialized, "{\"value\":\"secret\"}");
}
/// Test to ensure that the [`ManagedSecretValue`] debug representation does not leak the secret value.
#[test]
fn test_debug_representation_no_secrets() {
let secret = ManagedSecretValue::RawValue {
value: "secret".to_string(),
};
let debug_representation = format!("{:?}", secret);
assert!(
!debug_representation.contains("secret"),
"debug representation contains secret value: {debug_representation}"
);
}
/// Test to ensure that `anthropic_api_key` secrets are serialized in the format that the server expects.
#[test]
fn test_serialize_anthropic_api_key() {
let secret = ManagedSecretValue::AnthropicApiKey {
api_key: "sk-ant-test-key".to_string(),
};
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
assert_eq!(serialized, "{\"api_key\":\"sk-ant-test-key\"}");
}
/// Test to ensure that the [`ManagedSecretValue::AnthropicApiKey`] debug representation does not leak the API key.
#[test]
fn test_debug_representation_no_secrets_anthropic_api_key() {
let secret = ManagedSecretValue::AnthropicApiKey {
api_key: "sk-ant-secret-key".to_string(),
};
let debug_representation = format!("{:?}", secret);
assert!(
!debug_representation.contains("sk-ant-secret-key"),
"debug representation contains secret value: {debug_representation}"
);
}
/// Test to ensure that `anthropic_bedrock_api_key` secrets are serialized in the format that the server expects.
#[test]
fn test_serialize_anthropic_bedrock_api_key() {
let secret = ManagedSecretValue::AnthropicBedrockApiKey {
aws_bearer_token_bedrock: "test-token".to_string(),
aws_region: "us-east-1".to_string(),
};
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
assert_eq!(
serialized,
"{\"aws_bearer_token_bedrock\":\"test-token\",\"aws_region\":\"us-east-1\"}"
);
}
/// Test to ensure that `anthropic_bedrock_access_key` secrets are serialized in the format that the server expects.
#[test]
fn test_serialize_anthropic_bedrock_access_key() {
let secret = ManagedSecretValue::AnthropicBedrockAccessKey {
aws_access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
aws_session_token: Some("FwoGZXIvYXdzEBY".to_string()),
aws_region: "us-east-1".to_string(),
};
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
assert_eq!(
serialized,
"{\"aws_access_key_id\":\"AKIAIOSFODNN7EXAMPLE\",\"aws_secret_access_key\":\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\",\"aws_session_token\":\"FwoGZXIvYXdzEBY\",\"aws_region\":\"us-east-1\"}"
);
}
/// Test to ensure that an `anthropic_bedrock_access_key` secret with no session
/// token (i.e. persistent IAM credentials) omits the `aws_session_token` field
/// from the JSON payload sent to the server.
#[test]
fn test_serialize_anthropic_bedrock_access_key_without_session_token() {
let secret = ManagedSecretValue::AnthropicBedrockAccessKey {
aws_access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
aws_session_token: None,
aws_region: "us-east-1".to_string(),
};
let serialized = serde_json::to_string(&secret).expect("failed to serialize");
assert_eq!(
serialized,
"{\"aws_access_key_id\":\"AKIAIOSFODNN7EXAMPLE\",\"aws_secret_access_key\":\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\",\"aws_region\":\"us-east-1\"}"
);
assert!(
!serialized.contains("aws_session_token"),
"aws_session_token must not appear in serialized JSON when None: {serialized}"
);
}
/// Test that the constructor helper correctly passes an optional session token through.
#[test]
fn test_anthropic_bedrock_access_key_constructor_optional_session_token() {
let with_token = ManagedSecretValue::anthropic_bedrock_access_key(
"AKID",
"secret",
Some("token".to_string()),
"us-east-1",
);
match with_token {
ManagedSecretValue::AnthropicBedrockAccessKey {
aws_session_token, ..
} => assert_eq!(aws_session_token.as_deref(), Some("token")),
_ => panic!("unexpected variant"),
}
let without_token =
ManagedSecretValue::anthropic_bedrock_access_key("AKID", "secret", None, "us-east-1");
match without_token {
ManagedSecretValue::AnthropicBedrockAccessKey {
aws_session_token, ..
} => assert!(aws_session_token.is_none()),
_ => panic!("unexpected variant"),
}
}
/// Test to ensure that the [`ManagedSecretValue::AnthropicBedrockAccessKey`] debug representation does not leak secrets.
#[test]
fn test_debug_representation_no_secrets_anthropic_bedrock_access_key() {
let secret = ManagedSecretValue::AnthropicBedrockAccessKey {
aws_access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
aws_session_token: Some("FwoGZXIvYXdzEBY".to_string()),
aws_region: "us-west-2".to_string(),
};
let debug_representation = format!("{:?}", secret);
assert!(
!debug_representation.contains("AKIAIOSFODNN7EXAMPLE"),
"debug representation contains aws_access_key_id: {debug_representation}"
);
assert!(
!debug_representation.contains("wJalrXUtnFEMI"),
"debug representation contains aws_secret_access_key: {debug_representation}"
);
assert!(
!debug_representation.contains("FwoGZXIvYXdzEBY"),
"debug representation contains aws_session_token: {debug_representation}"
);
assert!(
!debug_representation.contains("us-west-2"),
"debug representation contains aws_region: {debug_representation}"
);
}
/// Test to ensure that the [`ManagedSecretValue::AnthropicBedrockApiKey`] debug representation does not leak secrets.
#[test]
fn test_debug_representation_no_secrets_anthropic_bedrock_api_key() {
let secret = ManagedSecretValue::AnthropicBedrockApiKey {
aws_bearer_token_bedrock: "secret-token".to_string(),
aws_region: "us-west-2".to_string(),
};
let debug_representation = format!("{:?}", secret);
assert!(
!debug_representation.contains("secret-token"),
"debug representation contains secret value: {debug_representation}"
);
assert!(
!debug_representation.contains("us-west-2"),
"debug representation contains aws_region: {debug_representation}"
);
}