use crate::errors::{Error, Result};
use crate::generators::generate_secret_value;
use crate::seed::SecretsStore;
use crate::sink::SecretsSink;
use greentic_secrets_spec::{
ManagedSecret, PackSecretRequirement, Scope, SecretSet, SecretSource, canonical_secret_uri,
generated_scope_team, normalize_team,
};
use greentic_types::secrets::SecretFormat;
pub fn discover_secret_set(
scope: Scope,
category: &str,
requirements: &[PackSecretRequirement],
) -> Result<SecretSet> {
let scope = Scope::new(scope.env(), scope.tenant(), normalize_team(scope.team()))?;
let mut set = SecretSet::new(scope.clone());
for req in requirements {
let team = match &req.generated {
Some(generated) => generated_scope_team(generated, scope.team()),
None => scope.team(),
};
let uri = canonical_secret_uri(scope.env(), scope.tenant(), team, category, &req.key)?;
let mut managed = match &req.generated {
Some(generated) => ManagedSecret::generated(uri, generated.clone()),
None => ManagedSecret::user_supplied(uri),
};
managed.required = req.required;
set.push(managed);
}
Ok(set)
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ProvisionReport {
pub generated: Vec<String>,
pub already_present: Vec<String>,
pub missing_required: Vec<String>,
pub missing_optional: Vec<String>,
}
impl ProvisionReport {
pub fn is_satisfied(&self) -> bool {
self.missing_required.is_empty()
}
}
pub async fn provision(set: &SecretSet, store: &dyn SecretsStore) -> Result<ProvisionReport> {
let mut report = ProvisionReport::default();
for managed in &set.secrets {
let uri = managed.uri.to_string();
if store_has(store, &uri).await? {
report.already_present.push(uri);
continue;
}
match &managed.source {
SecretSource::Generated(generated) => {
let (bytes, format) = generate_secret_value(generated)?;
store.put(&uri, format, &bytes).await?;
report.generated.push(uri);
}
SecretSource::UserSupplied => {
if managed.required {
report.missing_required.push(uri);
} else {
report.missing_optional.push(uri);
}
}
}
}
Ok(report)
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct PromoteReport {
pub promoted: Vec<String>,
pub missing: Vec<String>,
}
pub async fn promote(
set: &SecretSet,
source: &dyn SecretsStore,
sink: &dyn SecretsSink,
) -> Result<PromoteReport> {
let mut report = PromoteReport::default();
for managed in &set.secrets {
let uri = managed.uri.to_string();
match source.get(&uri).await {
Ok(bytes) => {
let format = managed.format.clone().unwrap_or(SecretFormat::Text);
sink.put_secret(&managed.uri, &bytes, format).await?;
report.promoted.push(uri);
}
Err(Error::NotFound { .. }) => report.missing.push(uri),
Err(err) => return Err(err),
}
}
Ok(report)
}
async fn store_has(store: &dyn SecretsStore, uri: &str) -> Result<bool> {
match store.get(uri).await {
Ok(_) => Ok(true),
Err(Error::NotFound { .. }) => Ok(false),
Err(err) => Err(err),
}
}
#[cfg(all(test, feature = "dev-store"))]
mod tests {
use super::*;
use crate::seed::DevStore;
use greentic_secrets_spec::{GeneratedSecretRequirement, GeneratedSecretScope};
use tempfile::tempdir;
fn scope() -> Scope {
Scope::new("dev", "demo", None).unwrap()
}
fn webhook_generated() -> GeneratedSecretRequirement {
GeneratedSecretRequirement {
policy: "random".to_string(),
length: 32,
encoding: "raw_text".to_string(),
scope: GeneratedSecretScope {
level: "tenant".to_string(),
team: Some("_".to_string()),
},
regenerate_if_present: false,
}
}
fn requirements() -> Vec<PackSecretRequirement> {
vec![
PackSecretRequirement::user_supplied("api_key"),
PackSecretRequirement::generated("webhook_secret", webhook_generated()),
]
}
const WEBHOOK_URI: &str = "secrets://dev/demo/_/messaging-telegram/webhook_secret";
#[test]
fn discovery_classifies_generated_vs_supplied() {
let set = discover_secret_set(scope(), "messaging-telegram", &requirements()).unwrap();
assert_eq!(set.secrets.len(), 2);
assert_eq!(set.user_supplied().count(), 1);
assert_eq!(set.generated().count(), 1);
assert!(set.generated().any(|m| m.uri.to_string() == WEBHOOK_URI));
}
#[tokio::test]
async fn provision_mints_generated_and_flags_missing_supplied() {
let dir = tempdir().unwrap();
let store = DevStore::with_path(dir.path().join(".dev.secrets.env")).unwrap();
let set = discover_secret_set(scope(), "messaging-telegram", &requirements()).unwrap();
let report = provision(&set, &store).await.unwrap();
assert_eq!(report.generated.len(), 1, "webhook secret should be minted");
assert_eq!(
report.missing_required.len(),
1,
"api_key is operator-supplied"
);
assert!(!report.is_satisfied());
let first = store.get(WEBHOOK_URI).await.unwrap();
assert_eq!(first.len(), 32);
let report2 = provision(&set, &store).await.unwrap();
assert!(report2.generated.is_empty(), "idempotent: already present");
assert_eq!(
store.get(WEBHOOK_URI).await.unwrap(),
first,
"value unchanged"
);
}
#[tokio::test]
async fn promote_copies_present_values_and_reports_missing() {
let src_dir = tempdir().unwrap();
let dst_dir = tempdir().unwrap();
let source = DevStore::with_path(src_dir.path().join(".dev.secrets.env")).unwrap();
let sink_store = DevStore::with_path(dst_dir.path().join(".dev.secrets.env")).unwrap();
let set = discover_secret_set(scope(), "messaging-telegram", &requirements()).unwrap();
provision(&set, &source).await.unwrap();
let sink = crate::sink::StoreSink::new(sink_store);
let report = promote(&set, &source, &sink).await.unwrap();
assert_eq!(
report.promoted.len(),
1,
"generated webhook secret promoted"
);
assert_eq!(report.missing.len(), 1, "api_key never supplied");
assert_eq!(
sink.store().get(WEBHOOK_URI).await.unwrap(),
source.get(WEBHOOK_URI).await.unwrap()
);
}
}