use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use super::GatewayContractError;
use crate::auth::SecretBearer;
use crate::ids::{CredentialBindingName, ProviderCredentialName, ProviderId, SecretBackendName};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(transparent)]
pub struct ExternalSecretReference(String);
impl ExternalSecretReference {
pub fn new(value: &str) -> Result<Self, GatewayContractError> {
let valid = value.len() <= 1024
&& !value.chars().any(char::is_control)
&& value
.split_once('#')
.is_some_and(|(path, key)| Self::is_valid_path(path) && !key.is_empty());
if !valid {
return Err(GatewayContractError::new(
"source.reference",
"must be `<path>#<key>` within 1024 bytes: non-empty path segments other than `.` or `..` without `?`, `#`, `%`, or `\\`, a non-empty key, and no control characters",
));
}
Ok(Self(value.to_owned()))
}
#[must_use]
pub fn is_valid_path(path: &str) -> bool {
!path.contains(['?', '#', '%', '\\'])
&& path
.split('/')
.all(|segment| !segment.is_empty() && segment != "." && segment != "..")
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn path(&self) -> &str {
self.0.split_once('#').map_or(&self.0, |(path, _)| path)
}
#[must_use]
pub fn key(&self) -> &str {
self.0.split_once('#').map_or("", |(_, key)| key)
}
}
impl<'de> Deserialize<'de> for ExternalSecretReference {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::new(&value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum ProviderCredentialWriteSource {
Environment {
binding: CredentialBindingName,
},
ExternalSecret {
backend: SecretBackendName,
reference: ExternalSecretReference,
},
ManagedSecret {
secret: SecretBearer,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ProviderCredentialWrite {
pub name: ProviderCredentialName,
pub provider: ProviderId,
pub source: ProviderCredentialWriteSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ProviderCredentialState {
Active,
Revoked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum ProviderCredentialSourceView {
Environment {
binding: CredentialBindingName,
},
ExternalSecret {
backend: SecretBackendName,
reference: ExternalSecretReference,
},
ManagedSecret,
}
impl From<&ProviderCredentialWriteSource> for ProviderCredentialSourceView {
fn from(source: &ProviderCredentialWriteSource) -> Self {
match source {
ProviderCredentialWriteSource::Environment { binding } => Self::Environment {
binding: binding.clone(),
},
ProviderCredentialWriteSource::ExternalSecret { backend, reference } => {
Self::ExternalSecret {
backend: backend.clone(),
reference: reference.clone(),
}
}
ProviderCredentialWriteSource::ManagedSecret { .. } => Self::ManagedSecret,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ProviderCredentialView {
pub name: ProviderCredentialName,
pub provider: ProviderId,
pub source: ProviderCredentialSourceView,
pub state: ProviderCredentialState,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub rotated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub revoked_at: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod tests {
use super::{
ExternalSecretReference, ProviderCredentialSourceView, ProviderCredentialWrite,
ProviderCredentialWriteSource,
};
use serde_json::json;
#[test]
fn sources_decode_and_project_to_views() {
for source in [
json!({"environment": {"binding": "openai-prod"}}),
json!({"external_secret": {"backend": "vault-main", "reference": "kv/data/openai#key"}}),
] {
let write: ProviderCredentialWrite = serde_json::from_value(json!({
"name": "openai-key",
"provider": "openai",
"source": source,
}))
.expect("credential write decodes");
let view = ProviderCredentialSourceView::from(&write.source);
assert_eq!(serde_json::to_value(&view).expect("view encodes"), source);
}
}
#[test]
fn managed_secret_is_write_only_and_projects_to_a_unit_view() {
let write: ProviderCredentialWrite = serde_json::from_value(json!({
"name": "openai-key",
"provider": "openai",
"source": {"managed_secret": {"secret": "sk-live-SECRET"}},
}))
.expect("managed credential write decodes");
let ProviderCredentialWriteSource::ManagedSecret { secret } = &write.source else {
panic!("managed secret source decoded as another variant");
};
assert_eq!(secret.expose(), "sk-live-SECRET");
assert!(
!format!("{write:?}").contains("sk-live-SECRET"),
"debug output discloses the submitted secret"
);
let view = ProviderCredentialSourceView::from(&write.source);
let encoded = serde_json::to_value(&view).expect("view encodes");
assert_eq!(encoded, json!("managed_secret"));
assert!(
!encoded.to_string().contains("sk-live-SECRET"),
"redacted view discloses the submitted secret"
);
}
#[test]
fn invalid_sources_are_rejected() {
for source in [
json!({"kms": {"key": "x"}}),
json!({"managed_secret": {"secret": "sk", "key_version": "v1"}}),
json!({"environment": {"binding": "/etc/secret"}}),
json!({"environment": {"binding": "openai", "path": "/etc/secret"}}),
json!({"external_secret": {"backend": "vault", "reference": ""}}),
] {
assert!(
serde_json::from_value::<ProviderCredentialWrite>(json!({
"name": "openai-key",
"provider": "openai",
"source": source,
}))
.is_err(),
"{source}"
);
}
}
#[test]
fn vault_references_follow_path_key_grammar() {
let reference = ExternalSecretReference::new("tenants/acme/openai#api#key").expect("valid");
assert_eq!(reference.path(), "tenants/acme/openai");
assert_eq!(reference.key(), "api#key");
let long = format!("{}#k", "a".repeat(1023));
for invalid in [
"",
"no-key",
"path#",
"#key",
"a//b#k",
"/a#k",
"a/#k",
"a/./b#k",
"a/../b#k",
"a?b#k",
"a%2fb#k",
"a\\b#k",
"a\nb#k",
long.as_str(),
] {
let error = ExternalSecretReference::new(invalid).expect_err(invalid);
assert_eq!(error.field, "source.reference");
}
}
}