use google_cloud_secretmanager_v1::client::SecretManagerService;
use google_cloud_secretmanager_v1::model::Replication;
use google_cloud_secretmanager_v1::model::Secret;
use google_cloud_secretmanager_v1::model::SecretPayload;
use google_cloud_secretmanager_v1::model::replication;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use super::Provider;
use super::ProviderUrl;
use crate::MonosecretError;
use crate::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcsmConfig {
pub project_id: String,
}
fn validate_gcp_project_id(project_id: &str) -> std::result::Result<(), MonosecretError> {
let len = project_id.len();
if !(6..=30).contains(&len) {
return Err(MonosecretError::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(MonosecretError::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(MonosecretError::ProviderOperationFailed(format!(
"GCP project ID contains invalid character '{c}'. \
Only lowercase letters, digits, and hyphens are allowed"
)));
}
}
if project_id.ends_with('-') {
return Err(MonosecretError::ProviderOperationFailed(
"GCP project ID cannot end with a hyphen".to_string(),
));
}
Ok(())
}
impl TryFrom<&ProviderUrl> for GcsmConfig {
type Error = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "gcsm" {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for gcsm provider. Expected 'gcsm'.",
url.scheme()
)));
}
let project_id = url.host().filter(|s| !s.is_empty()).ok_or_else(|| {
MonosecretError::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(MonosecretError::ProviderOperationFailed(format!(
"{name} cannot be empty"
)));
}
for c in component.chars() {
if !c.is_ascii_alphanumeric() && c != '_' && c != '-' {
return Err(MonosecretError::ProviderOperationFailed(format!(
"{name} contains invalid character '{c}'. \
Only alphanumeric characters, underscores, and hyphens are allowed"
)));
}
}
Ok(())
}
fn format_secret_name(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!("monosecret-{project}-{profile}-{key}");
if secret_name.len() > 255 {
return Err(MonosecretError::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| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to create GCP Secret Manager client: {e}\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"
))
})
}
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| {
MonosecretError::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(MonosecretError::ProviderOperationFailed(format!(
"Failed to access secret '{secret_name}': {e}"
)))
}
}
}
}
#[allow(clippy::collapsible_if)]
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(MonosecretError::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| {
MonosecretError::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
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_secret_name_builds_valid_name() {
let name = GcsmProvider::format_secret_name("my-project", "default", "API_KEY").unwrap();
insta::assert_snapshot!(name);
}
#[test]
fn format_secret_name_rejects_empty_component() {
let errors = [
GcsmProvider::format_secret_name("", "default", "KEY").unwrap_err(),
GcsmProvider::format_secret_name("proj", "", "KEY").unwrap_err(),
GcsmProvider::format_secret_name("proj", "default", "").unwrap_err(),
]
.map(|err| err.to_string())
.join("\n");
insta::assert_snapshot!(errors);
}
#[test]
fn format_secret_name_rejects_invalid_characters() {
let errors = [
GcsmProvider::format_secret_name("bad/project", "default", "KEY").unwrap_err(),
GcsmProvider::format_secret_name("proj", "default", "bad key").unwrap_err(),
]
.map(|err| err.to_string())
.join("\n");
insta::assert_snapshot!(errors);
}
#[test]
fn format_secret_name_rejects_overlong_name() {
let long = "a".repeat(300);
let err = GcsmProvider::format_secret_name("proj", "default", &long).unwrap_err();
insta::assert_snapshot!(err.to_string());
}
#[test]
fn get_rejects_invalid_key_before_network() {
let provider = GcsmProvider::new(GcsmConfig {
project_id: "valid-proj".to_string(),
});
let err = provider.get("project", "bad key", "default").unwrap_err();
insta::assert_snapshot!(err.to_string());
}
#[test]
fn set_rejects_invalid_key_before_network() {
let provider = GcsmProvider::new(GcsmConfig {
project_id: "valid-proj".to_string(),
});
let err = provider
.set(
"project",
"bad/key",
&SecretString::new("v".into()),
"default",
)
.unwrap_err();
insta::assert_snapshot!(err.to_string());
}
}