use super::{Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use google_cloud_secretmanager_v1::client::SecretManagerService;
use google_cloud_secretmanager_v1::model::{Replication, Secret, SecretPayload, replication};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcsmConfig {
pub project_id: String,
}
fn validate_gcp_project_id(project_id: &str) -> std::result::Result<(), SecretSpecError> {
let len = project_id.len();
if len < 6 || len > 30 {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"GCP project ID must be 6-30 characters, got {}",
len
)));
}
let mut chars = project_id.chars().peekable();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => {
return Err(SecretSpecError::ProviderOperationFailed(
"GCP project ID must start with a lowercase letter".to_string(),
));
}
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"GCP project ID contains invalid character '{}'. \
Only lowercase letters, digits, and hyphens are allowed",
c
)));
}
}
if project_id.ends_with('-') {
return Err(SecretSpecError::ProviderOperationFailed(
"GCP project ID cannot end with a hyphen".to_string(),
));
}
Ok(())
}
impl TryFrom<&ProviderUrl> for GcsmConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "gcsm" {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for gcsm provider. Expected 'gcsm'.",
url.scheme()
)));
}
let project_id = url.host().filter(|s| !s.is_empty()).ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"GCP project ID is required. Use format: gcsm://project-id".to_string(),
)
})?;
validate_gcp_project_id(&project_id)?;
Ok(Self { project_id })
}
}
pub struct GcsmProvider {
config: GcsmConfig,
}
crate::register_provider! {
struct: GcsmProvider,
config: GcsmConfig,
name: "gcsm",
description: "Google Cloud Secret Manager",
schemes: ["gcsm"],
examples: ["gcsm://my-gcp-project"],
}
impl GcsmProvider {
pub fn new(config: GcsmConfig) -> Self {
Self { config }
}
fn validate_name_component(name: &str, component: &str) -> Result<()> {
if component.is_empty() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"{} cannot be empty",
name
)));
}
for c in component.chars() {
if !c.is_ascii_alphanumeric() && c != '_' && c != '-' {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"{} contains invalid character '{}'. \
Only alphanumeric characters, underscores, and hyphens are allowed",
name, c
)));
}
}
Ok(())
}
fn format_secret_name(&self, project: &str, profile: &str, key: &str) -> Result<String> {
Self::validate_name_component("project", project)?;
Self::validate_name_component("profile", profile)?;
Self::validate_name_component("key", key)?;
let secret_name = format!("secretspec-{}-{}-{}", project, profile, key);
if secret_name.len() > 255 {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Secret name too long: {} characters (max 255)",
secret_name.len()
)));
}
Ok(secret_name)
}
fn is_not_found_error(e: &impl std::error::Error) -> bool {
let s = e.to_string();
s.contains("NOT_FOUND") || s.contains("notFound")
}
fn is_already_exists_error(e: &impl std::error::Error) -> bool {
let s = e.to_string();
s.contains("ALREADY_EXISTS") || s.contains("alreadyExists")
}
async fn create_client(&self) -> Result<SecretManagerService> {
SecretManagerService::builder().build().await.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to create GCP Secret Manager client: {}\n\n\
Ensure Application Default Credentials are configured:\n \
- Local development: Run 'gcloud auth application-default login'\n \
- Service account: Set GOOGLE_APPLICATION_CREDENTIALS environment variable\n \
- GKE: Configure Workload Identity",
e
))
})
}
async fn get_secret_async(
&self,
project: &str,
key: &str,
profile: &str,
) -> Result<Option<SecretString>> {
let secret_name = self.format_secret_name(project, profile, key)?;
let secret_version_path = format!(
"projects/{}/secrets/{}/versions/latest",
self.config.project_id, secret_name
);
let client = self.create_client().await?;
match client
.access_secret_version()
.set_name(&secret_version_path)
.send()
.await
{
Ok(response) => {
if let Some(payload) = response.payload {
let data = String::from_utf8(payload.data.to_vec()).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Secret data is not valid UTF-8: {}",
e
))
})?;
Ok(Some(SecretString::new(data.into())))
} else {
Ok(None)
}
}
Err(e) => {
if Self::is_not_found_error(&e) {
Ok(None)
} else {
Err(SecretSpecError::ProviderOperationFailed(format!(
"Failed to access secret '{}': {}",
secret_name, e
)))
}
}
}
}
async fn set_secret_async(
&self,
project: &str,
key: &str,
value: &SecretString,
profile: &str,
) -> Result<()> {
let secret_name = self.format_secret_name(project, profile, key)?;
let client = self.create_client().await?;
let create_result = client
.create_secret()
.set_parent(format!("projects/{}", self.config.project_id))
.set_secret_id(&secret_name)
.set_secret(Secret::default().set_replication(
Replication::default().set_automatic(replication::Automatic::default()),
))
.send()
.await;
if let Err(e) = create_result {
if !Self::is_already_exists_error(&e) {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Failed to create secret '{}': {}",
secret_name, e
)));
}
}
client
.add_secret_version()
.set_parent(format!(
"projects/{}/secrets/{}",
self.config.project_id, secret_name
))
.set_payload(
SecretPayload::default().set_data(value.expose_secret().as_bytes().to_vec()),
)
.send()
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to add secret version for '{}': {}",
secret_name, e
))
})?;
Ok(())
}
}
impl Provider for GcsmProvider {
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
format!("gcsm://{}", self.config.project_id)
}
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
super::block_on(self.get_secret_async(project, key, profile))
}
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
super::block_on(self.set_secret_async(project, key, value, profile))
}
fn allows_set(&self) -> bool {
true
}
}