use std::io::Write as _;
#[cfg(feature = "nats")]
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::audit::{AuditEntry, AuditExecContext, AuditStatus};
use crate::lifecycle::classify_operation;
pub fn key_ref(profile: &str, key: &str) -> String {
let mut h = Sha256::new();
h.update(profile.as_bytes());
h.update(b":");
h.update(key.as_bytes());
format!("{:x}", h.finalize())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudEvent {
pub specversion: String,
pub id: String,
pub source: String,
#[serde(rename = "type")]
pub event_type: String,
pub time: DateTime<Utc>,
pub datacontenttype: String,
pub data: serde_json::Value,
}
impl CloudEvent {
pub fn new(source: &str, event_type: impl Into<String>, data: serde_json::Value) -> Self {
Self {
specversion: "1.0".to_string(),
id: Uuid::new_v4().to_string(),
source: source.to_string(),
event_type: event_type.into(),
time: Utc::now(),
datacontenttype: "application/json".to_string(),
data,
}
}
pub fn from_audit(entry: &AuditEntry) -> Self {
let source = format!("tsafe/{}", entry.profile);
let classification = classify_operation(&entry.operation);
let event_type = audit_op_to_ce_type(&entry.operation);
let computed_key_ref = entry.key.as_deref().map(|k| key_ref(&entry.profile, k));
let mut data = serde_json::Map::new();
data.insert("audit_id".into(), serde_json::json!(entry.id));
data.insert("operation".into(), serde_json::json!(entry.operation));
data.insert("key_ref".into(), serde_json::json!(computed_key_ref));
data.insert(
"status".into(),
serde_json::json!(if entry.status == AuditStatus::Success {
"success"
} else {
"failure"
}),
);
data.insert("message".into(), serde_json::json!(entry.message));
if let Some(state) = classification.lifecycle_state {
data.insert(
"lifecycle".into(),
serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
);
}
if let Some(exec) = entry
.context
.as_ref()
.and_then(|context| context.exec.as_ref())
{
data.insert(
"authority".into(),
serde_json::json!({
"exec": project_exec_context(&entry.profile, exec),
}),
);
}
let mut ce = Self::new(&source, event_type, serde_json::Value::Object(data));
ce.time = entry.timestamp; ce
}
}
fn project_exec_context(profile: &str, exec: &AuditExecContext) -> serde_json::Value {
serde_json::json!({
"contract_name": exec.contract_name,
"target": exec.target,
"target_decision": exec.target_decision,
"matched_target": exec.matched_target,
"authority_profile": exec.authority_profile,
"authority_namespace": exec.authority_namespace,
"trust_level": exec.trust_level.map(|value| value.as_str()),
"access_profile": exec.access_profile.map(|value| value.as_str()),
"inherit": exec.inherit.map(|value| value.as_str()),
"deny_dangerous_env": exec.deny_dangerous_env,
"redact_output": exec.redact_output,
"network": exec.network.map(|value| value.as_str()),
"allowed_secret_refs": hash_names(profile, &exec.allowed_secrets),
"required_secret_refs": hash_names(profile, &exec.required_secrets),
"injected_secret_refs": hash_names(profile, &exec.injected_secrets),
"missing_required_secret_refs": hash_names(profile, &exec.missing_required_secrets),
"dropped_env_names": exec.dropped_env_names,
"target_allowed": exec.target_allowed,
})
}
fn hash_names(profile: &str, names: &[String]) -> Vec<String> {
let mut out = names
.iter()
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.map(|name| key_ref(profile, name))
.collect::<Vec<_>>();
out.sort();
out.dedup();
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NatsAdapterConfig {
url: String,
subject: String,
}
fn nats_adapter_from_env() -> Option<NatsAdapterConfig> {
let url = std::env::var("TSAFE_EVENTS_NATS_URL").ok()?;
let subject = std::env::var("TSAFE_EVENTS_NATS_SUBJECT").ok()?;
let url = url.trim();
let subject = subject.trim();
if url.is_empty() || subject.is_empty() {
return None;
}
Some(NatsAdapterConfig {
url: url.to_string(),
subject: subject.to_string(),
})
}
#[cfg(any(test, feature = "nats"))]
fn publish_nats_with<F>(cfg: &NatsAdapterConfig, line: &str, publish: F) -> Result<(), String>
where
F: FnOnce(&str, &str, &[u8]) -> Result<(), String>,
{
publish(&cfg.url, &cfg.subject, line.as_bytes())
}
#[cfg(feature = "nats")]
fn publish_nats(cfg: &NatsAdapterConfig, line: &str) -> Result<(), String> {
publish_nats_with(cfg, line, |url, subject, payload| {
let connection = nats::Options::new()
.with_name("tsafe-events")
.connect(url)
.map_err(|error| format!("connect failed: {error}"))?;
connection
.publish(subject, payload)
.map_err(|error| format!("publish failed: {error}"))?;
connection
.flush_timeout(Duration::from_secs(2))
.map_err(|error| format!("flush failed: {error}"))?;
Ok(())
})
}
#[cfg(not(feature = "nats"))]
fn warn_nats_feature_disabled() {
static WARN_ONCE: std::sync::Once = std::sync::Once::new();
WARN_ONCE.call_once(|| {
tracing::warn!(
"TSAFE_EVENTS_NATS_URL/TSAFE_EVENTS_NATS_SUBJECT were set, but tsafe-core was built without the `nats` feature"
);
});
}
pub fn audit_op_to_ce_type(operation: &str) -> String {
if let Some(event_type) = classify_operation(operation).event_type {
event_type.to_string()
} else {
format!("com.tsafe.{operation}.v1")
}
}
pub fn emit(event: &CloudEvent) {
let line = match serde_json::to_string(event) {
Ok(s) => s,
Err(_) => return,
};
if let Ok(outbox) = std::env::var("TSAFE_EVENTS_OUTBOX") {
if !outbox.is_empty() {
if outbox == "-" || outbox.eq_ignore_ascii_case("stderr") {
let _ = writeln!(std::io::stderr(), "{line}");
} else {
let _ = (|| -> std::io::Result<()> {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&outbox)?;
writeln!(f, "{line}")
})();
}
}
}
if let Ok(url) = std::env::var("TSAFE_EVENTS_WEBHOOK_URL") {
if url.starts_with("https://") {
let line_cloned = line.clone();
let url_cloned = url.clone();
std::thread::spawn(move || {
if let Err(e) = ureq::post(&url_cloned)
.set("Content-Type", "application/cloudevents+json")
.send_string(&line_cloned)
{
tracing::warn!(url = %url_cloned, error = %e, "events webhook POST failed");
}
});
}
}
if let Some(cfg) = nats_adapter_from_env() {
#[cfg(feature = "nats")]
{
let line_cloned = line.clone();
std::thread::spawn(move || {
if let Err(error) = publish_nats(&cfg, &line_cloned) {
tracing::warn!(
url = %cfg.url,
subject = %cfg.subject,
error = %error,
"events NATS publish failed"
);
}
});
}
#[cfg(not(feature = "nats"))]
{
let _ = cfg;
warn_nats_feature_disabled();
}
}
}
#[tracing::instrument(skip(key), fields(profile = %profile, operation = %operation))]
pub fn emit_event(profile: &str, operation: &str, key: Option<&str>) {
let entry = AuditEntry::success(profile, operation, key);
emit(&CloudEvent::from_audit(&entry));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::{AuditContext, AuditEntry, AuditExecContext};
use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
#[test]
fn key_ref_is_deterministic_and_opaque() {
let r1 = key_ref("dev", "DB_PASSWORD");
let r2 = key_ref("dev", "DB_PASSWORD");
assert_eq!(r1, r2, "same inputs must produce same ref");
assert!(
!r1.contains("DB_PASSWORD"),
"key name must not appear in ref"
);
assert_eq!(r1.len(), 64, "SHA-256 hex is 64 chars");
}
#[test]
fn key_ref_differs_across_profiles() {
let r_dev = key_ref("dev", "API_KEY");
let r_prod = key_ref("prod", "API_KEY");
assert_ne!(r_dev, r_prod, "same key in different profiles must differ");
}
#[test]
fn from_audit_happy_path() {
let entry = AuditEntry::success("main", "set", Some("MY_KEY"));
let ce = CloudEvent::from_audit(&entry);
assert_eq!(ce.specversion, "1.0");
assert_eq!(ce.source, "tsafe/main");
assert_eq!(ce.event_type, "com.tsafe.vault.secret.set.v1");
assert_eq!(ce.datacontenttype, "application/json");
assert_eq!(ce.time, entry.timestamp);
let data = &ce.data;
assert_eq!(data["audit_id"], entry.id);
assert_eq!(data["operation"], "set");
assert_eq!(data["status"], "success");
let kr = data["key_ref"].as_str().unwrap();
assert_eq!(kr.len(), 64);
assert!(!kr.contains("MY_KEY"));
}
#[test]
fn from_audit_projects_lifecycle_state_for_vault_and_share_ops() {
let created = CloudEvent::from_audit(&AuditEntry::success("main", "init", None));
assert_eq!(created.data["lifecycle"]["domain"], "vault");
assert_eq!(created.data["lifecycle"]["state"], "created");
let shared = CloudEvent::from_audit(&AuditEntry::success("main", "share-once", None));
assert_eq!(shared.data["lifecycle"]["domain"], "share");
assert_eq!(shared.data["lifecycle"]["state"], "published");
}
#[test]
fn from_audit_projects_lifecycle_state_for_secret_ops() {
let written = CloudEvent::from_audit(&AuditEntry::success("main", "set", Some("K")));
assert_eq!(written.data["lifecycle"]["domain"], "secret");
assert_eq!(written.data["lifecycle"]["state"], "written");
let accessed = CloudEvent::from_audit(&AuditEntry::success("main", "get", Some("K")));
assert_eq!(accessed.data["lifecycle"]["domain"], "secret");
assert_eq!(accessed.data["lifecycle"]["state"], "accessed");
let deleted = CloudEvent::from_audit(&AuditEntry::success("main", "delete", Some("K")));
assert_eq!(deleted.data["lifecycle"]["domain"], "secret");
assert_eq!(deleted.data["lifecycle"]["state"], "deleted");
let imported = CloudEvent::from_audit(&AuditEntry::success("main", "import", None));
assert_eq!(imported.data["lifecycle"]["domain"], "secret");
assert_eq!(imported.data["lifecycle"]["state"], "imported");
let exported = CloudEvent::from_audit(&AuditEntry::success("main", "export", None));
assert_eq!(exported.data["lifecycle"]["domain"], "secret");
assert_eq!(exported.data["lifecycle"]["state"], "exported");
let namespace_copy = CloudEvent::from_audit(&AuditEntry::success("main", "ns-copy", None));
assert_eq!(namespace_copy.data["lifecycle"]["domain"], "secret");
assert_eq!(namespace_copy.data["lifecycle"]["state"], "written");
}
#[test]
fn from_audit_projects_lifecycle_state_for_surface_aliases() {
let created = CloudEvent::from_audit(&AuditEntry::success("main", "create", None));
assert_eq!(created.data["lifecycle"]["domain"], "vault");
assert_eq!(created.data["lifecycle"]["state"], "created");
assert_eq!(created.event_type, "com.tsafe.vault.created.v1");
let team_created = CloudEvent::from_audit(&AuditEntry::success("main", "team-init", None));
assert_eq!(team_created.data["lifecycle"]["domain"], "vault");
assert_eq!(team_created.data["lifecycle"]["state"], "created");
assert_eq!(team_created.event_type, "com.tsafe.vault.created.v1");
let namespace_move = CloudEvent::from_audit(&AuditEntry::success("main", "ns-move", None));
assert_eq!(namespace_move.data["lifecycle"]["domain"], "vault");
assert_eq!(namespace_move.data["lifecycle"]["state"], "secret_moved");
assert_eq!(namespace_move.event_type, "com.tsafe.ns-move.v1");
}
#[test]
fn from_audit_projects_lifecycle_state_for_policy_and_helper_ops() {
let policy_set = CloudEvent::from_audit(&AuditEntry::success("main", "policy-set", None));
assert_eq!(policy_set.data["lifecycle"]["domain"], "policy");
assert_eq!(policy_set.data["lifecycle"]["state"], "set");
assert_eq!(policy_set.event_type, "com.tsafe.policy-set.v1");
let rotate_due = CloudEvent::from_audit(&AuditEntry::success("main", "rotate-due", None));
assert_eq!(rotate_due.data["lifecycle"]["domain"], "policy");
assert_eq!(rotate_due.data["lifecycle"]["state"], "due_checked");
assert_eq!(rotate_due.event_type, "com.tsafe.secret.rotation_due.v1");
let helper_get =
CloudEvent::from_audit(&AuditEntry::success("main", "credential-helper-get", None));
assert_eq!(helper_get.data["lifecycle"]["domain"], "credential_helper");
assert_eq!(helper_get.data["lifecycle"]["state"], "accessed");
assert_eq!(helper_get.event_type, "com.tsafe.credential-helper-get.v1");
let helper_erase = CloudEvent::from_audit(&AuditEntry::success(
"main",
"credential-helper-erase",
None,
));
assert_eq!(
helper_erase.data["lifecycle"]["domain"],
"credential_helper"
);
assert_eq!(helper_erase.data["lifecycle"]["state"], "erased");
assert_eq!(
helper_erase.event_type,
"com.tsafe.credential-helper-erase.v1"
);
}
#[test]
fn from_audit_projects_lifecycle_state_for_team_membership_ops() {
let added = CloudEvent::from_audit(&AuditEntry::success("main", "team-add-member", None));
assert_eq!(added.data["lifecycle"]["domain"], "team");
assert_eq!(added.data["lifecycle"]["state"], "member_added");
assert_eq!(added.event_type, "com.tsafe.team-add-member.v1");
let removed =
CloudEvent::from_audit(&AuditEntry::success("main", "team-remove-member", None));
assert_eq!(removed.data["lifecycle"]["domain"], "team");
assert_eq!(removed.data["lifecycle"]["state"], "member_removed");
assert_eq!(removed.event_type, "com.tsafe.team-remove-member.v1");
}
#[test]
fn from_audit_projects_lifecycle_state_for_session_and_sync_ops() {
let unlocked = CloudEvent::from_audit(&AuditEntry::success("main", "unlock", None));
assert_eq!(unlocked.data["lifecycle"]["domain"], "session");
assert_eq!(unlocked.data["lifecycle"]["state"], "unlocked");
let pulled = CloudEvent::from_audit(&AuditEntry::success("main", "kv-pull", None));
assert_eq!(pulled.data["lifecycle"]["domain"], "sync");
assert_eq!(pulled.data["lifecycle"]["state"], "pull_completed");
let merged = CloudEvent::from_audit(&AuditEntry::success("main", "sync", None));
assert_eq!(merged.data["lifecycle"]["domain"], "sync");
assert_eq!(merged.data["lifecycle"]["state"], "merged");
}
#[test]
fn from_audit_no_key() {
let entry = AuditEntry::success("main", "export", None);
let ce = CloudEvent::from_audit(&entry);
assert_eq!(ce.data["key_ref"], serde_json::Value::Null);
}
#[test]
fn from_audit_projects_exec_authority_without_plaintext_secret_names() {
let contract = AuthorityContract {
name: "deploy".into(),
profile: Some("work".into()),
namespace: Some("infra".into()),
access_profile: crate::rbac::RbacProfile::ReadOnly,
allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
required_secrets: vec!["DB_PASSWORD".into()],
allowed_targets: vec!["terraform".into()],
trust: AuthorityTrust::Hardened,
network: AuthorityNetworkPolicy::Restricted,
};
let entry = AuditEntry::success("dev", "exec", None).with_context(AuditContext::from_exec(
AuditExecContext::from_contract(&contract)
.with_target("/usr/bin/terraform")
.with_injected_secrets(["DB_PASSWORD"])
.with_missing_required_secrets(["API_KEY"])
.with_dropped_env_names(["OPENAI_API_KEY"])
.with_target_evaluation(&contract.evaluate_target(Some("/usr/bin/terraform"))),
));
let ce = CloudEvent::from_audit(&entry);
let exec = &ce.data["authority"]["exec"];
assert_eq!(exec["contract_name"], "deploy");
assert_eq!(exec["target_decision"], "allowed_basename");
assert_eq!(exec["matched_target"], "terraform");
assert_eq!(exec["trust_level"], "hardened");
assert_eq!(exec["access_profile"], "read_only");
assert_eq!(exec["inherit"], "minimal");
assert_eq!(exec["network"], "restricted");
assert_eq!(
exec["dropped_env_names"],
serde_json::json!(["OPENAI_API_KEY"])
);
assert_eq!(exec["allowed_secret_refs"].as_array().unwrap().len(), 2);
let encoded = serde_json::to_string(&ce).unwrap();
assert!(!encoded.contains("DB_PASSWORD"));
assert!(!encoded.contains(r#""API_KEY""#));
}
#[test]
fn audit_op_mapping_coverage() {
for op in &[
"set",
"delete",
"get",
"init",
"rotate",
"export",
"import",
"exec",
"create",
"team-init",
"team-add-member",
"team-remove-member",
"policy-set",
"policy-remove",
"unlock",
"kv-pull",
"vault-pull",
"op-pull",
"pull",
"ns-copy",
"ns-move",
"credential-helper-get",
"credential-helper-store",
"credential-helper-erase",
"share-once",
"receive-once",
"snap",
"snap-receive",
"rotate-due",
] {
let t = audit_op_to_ce_type(op);
assert!(t.starts_with("com.tsafe."), "bad type for op '{op}': {t}");
assert!(
t.ends_with(".v1"),
"type must end with .v1 for op '{op}': {t}"
);
}
}
#[test]
fn unknown_op_gets_fallback_type() {
let t = audit_op_to_ce_type("custom-op");
assert_eq!(t, "com.tsafe.custom-op.v1");
}
#[test]
fn snapshot_restore_keeps_fallback_event_type() {
let t = audit_op_to_ce_type("snapshot-restore");
assert_eq!(t, "com.tsafe.snapshot-restore.v1");
}
#[test]
fn cloud_event_serialises_correctly() {
let entry = AuditEntry::success("dev", "set", Some("API_KEY"));
let ce = CloudEvent::from_audit(&entry);
let json = serde_json::to_string(&ce).unwrap();
assert!(json.contains(r#""specversion":"1.0""#));
assert!(json.contains(r#""type":"com.tsafe.vault.secret.set.v1""#));
assert!(json.contains(r#""datacontenttype":"application/json""#));
assert!(
!json.contains("API_KEY"),
"plaintext key name must not appear in event JSON"
);
}
#[test]
fn emit_to_file_outbox() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let outbox = dir.path().join("events.jsonl");
temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
emit_event("dev", "set", Some("SECRET_KEY"));
emit_event("dev", "delete", Some("OLD_KEY"));
});
let content = std::fs::read_to_string(&outbox).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2, "should have two JSONL lines");
for line in &lines {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(v["specversion"], "1.0");
assert_eq!(v["datacontenttype"], "application/json");
let source = v["source"].as_str().unwrap();
assert!(source.starts_with("tsafe/dev"));
assert!(!line.contains("SECRET_KEY"), "key name leaked into event");
assert!(!line.contains("OLD_KEY"), "key name leaked into event");
}
}
#[test]
fn emit_noop_when_no_env_vars() {
temp_env::with_vars(
[
("TSAFE_EVENTS_OUTBOX", None::<&str>),
("TSAFE_EVENTS_WEBHOOK_URL", None),
("TSAFE_EVENTS_NATS_URL", None),
("TSAFE_EVENTS_NATS_SUBJECT", None),
],
|| {
emit_event("dev", "get", Some("ANY_KEY")); },
);
}
#[test]
fn nats_adapter_requires_both_url_and_subject() {
temp_env::with_vars(
[
("TSAFE_EVENTS_NATS_URL", Some("nats://127.0.0.1:4222")),
("TSAFE_EVENTS_NATS_SUBJECT", None),
],
|| assert!(nats_adapter_from_env().is_none()),
);
temp_env::with_vars(
[
("TSAFE_EVENTS_NATS_URL", Some(" ")),
("TSAFE_EVENTS_NATS_SUBJECT", Some("events.tsafe")),
],
|| assert!(nats_adapter_from_env().is_none()),
);
}
#[test]
fn nats_adapter_reads_trimmed_env_values() {
temp_env::with_vars(
[
("TSAFE_EVENTS_NATS_URL", Some(" nats://127.0.0.1:4222 ")),
("TSAFE_EVENTS_NATS_SUBJECT", Some(" events.tsafe ")),
],
|| {
let cfg = nats_adapter_from_env().expect("expected NATS adapter config");
assert_eq!(cfg.url, "nats://127.0.0.1:4222");
assert_eq!(cfg.subject, "events.tsafe");
},
);
}
#[test]
fn publish_nats_with_uses_expected_target_and_payload() {
let cfg = NatsAdapterConfig {
url: "nats://127.0.0.1:4222".into(),
subject: "events.tsafe".into(),
};
let mut seen = None;
publish_nats_with(
&cfg,
"{\"type\":\"com.tsafe.test.v1\"}",
|url, subject, payload| {
seen = Some((
url.to_string(),
subject.to_string(),
String::from_utf8(payload.to_vec()).unwrap(),
));
Ok(())
},
)
.unwrap();
assert_eq!(
seen,
Some((
"nats://127.0.0.1:4222".into(),
"events.tsafe".into(),
"{\"type\":\"com.tsafe.test.v1\"}".into(),
))
);
}
#[test]
fn emit_event_convenience_wrapper() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let outbox = dir.path().join("e.jsonl");
temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
emit_event("main", "init", None);
});
let content = std::fs::read_to_string(&outbox).unwrap();
let line = content
.lines()
.next()
.expect("expected one JSONL line from emit");
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(v["type"], "com.tsafe.vault.created.v1");
}
#[test]
fn no_plaintext_secret_value_in_event_payload() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let outbox = dir.path().join("no-leak.jsonl");
let sensitive_key = "PROD_DB_PASSWORD";
let sensitive_value = "super-secret-plaintext-value-12345";
temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
emit_event("prod", "set", Some(sensitive_key));
emit_event("prod", "get", Some(sensitive_key));
emit_event("prod", "delete", Some(sensitive_key));
});
let content = std::fs::read_to_string(&outbox).unwrap();
assert!(
!content.contains(sensitive_key),
"plaintext key name must not appear in any emitted event (adapter: outbox file)"
);
assert!(
!content.contains(sensitive_value),
"plaintext secret value must not appear in any emitted event (adapter: outbox file)"
);
for line in content.lines() {
let v: serde_json::Value =
serde_json::from_str(line).expect("each emitted line must be valid JSON");
assert_eq!(v["specversion"], "1.0");
}
}
#[test]
fn no_plaintext_leak_in_exec_authority_event() {
use crate::audit::{AuditContext, AuditEntry, AuditExecContext};
use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
let contract = AuthorityContract {
name: "ci-deploy".into(),
profile: Some("prod".into()),
namespace: Some("infra".into()),
access_profile: crate::rbac::RbacProfile::ReadOnly,
allowed_secrets: vec!["DATABASE_URL".into(), "API_SECRET".into()],
required_secrets: vec!["DATABASE_URL".into()],
allowed_targets: vec!["deploy.sh".into()],
trust: AuthorityTrust::Hardened,
network: AuthorityNetworkPolicy::Restricted,
};
let entry =
AuditEntry::success("prod", "exec", None).with_context(AuditContext::from_exec(
AuditExecContext::from_contract(&contract)
.with_target("/scripts/deploy.sh")
.with_injected_secrets(["DATABASE_URL"])
.with_missing_required_secrets(["API_SECRET"])
.with_dropped_env_names(["OPENAI_API_KEY"])
.with_target_evaluation(&contract.evaluate_target(Some("/scripts/deploy.sh"))),
));
let ce = CloudEvent::from_audit(&entry);
let serialised = serde_json::to_string(&ce).unwrap();
assert!(
!serialised.contains("DATABASE_URL"),
"plaintext key name DATABASE_URL must not appear in exec authority event"
);
assert!(
!serialised.contains("API_SECRET"),
"plaintext key name API_SECRET must not appear in exec authority event"
);
let auth = &ce.data["authority"]["exec"];
assert!(
!auth.is_null(),
"authority.exec must be present for exec events"
);
let refs = auth["allowed_secret_refs"].as_array().unwrap();
assert_eq!(refs.len(), 2, "two allowed secrets should produce two refs");
for r in refs {
let s = r.as_str().unwrap();
assert_eq!(s.len(), 64, "each ref must be a 64-char SHA-256 hex string");
}
}
}