1use std::io::Write as _;
14#[cfg(feature = "nats")]
15use std::time::Duration;
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use uuid::Uuid;
21
22use crate::audit::{AuditEntry, AuditExecContext, AuditStatus};
23use crate::lifecycle::classify_operation;
24
25pub fn key_ref(profile: &str, key: &str) -> String {
34 let mut h = Sha256::new();
35 h.update(profile.as_bytes());
36 h.update(b":");
37 h.update(key.as_bytes());
38 format!("{:x}", h.finalize())
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CloudEvent {
50 pub specversion: String,
52 pub id: String,
54 pub source: String,
56 #[serde(rename = "type")]
58 pub event_type: String,
59 pub time: DateTime<Utc>,
61 pub datacontenttype: String,
63 pub data: serde_json::Value,
65}
66
67impl CloudEvent {
68 pub fn new(source: &str, event_type: impl Into<String>, data: serde_json::Value) -> Self {
70 Self {
71 specversion: "1.0".to_string(),
72 id: Uuid::new_v4().to_string(),
73 source: source.to_string(),
74 event_type: event_type.into(),
75 time: Utc::now(),
76 datacontenttype: "application/json".to_string(),
77 data,
78 }
79 }
80
81 pub fn from_audit(entry: &AuditEntry) -> Self {
87 let source = format!("tsafe/{}", entry.profile);
88 let classification = classify_operation(&entry.operation);
89 let event_type = audit_op_to_ce_type(&entry.operation);
90 let computed_key_ref = entry.key.as_deref().map(|k| key_ref(&entry.profile, k));
91 let mut data = serde_json::Map::new();
92 data.insert("audit_id".into(), serde_json::json!(entry.id));
93 data.insert("operation".into(), serde_json::json!(entry.operation));
94 data.insert("key_ref".into(), serde_json::json!(computed_key_ref));
95 data.insert(
96 "status".into(),
97 serde_json::json!(if entry.status == AuditStatus::Success {
98 "success"
99 } else {
100 "failure"
101 }),
102 );
103 data.insert("message".into(), serde_json::json!(entry.message));
104 if let Some(state) = classification.lifecycle_state {
105 data.insert(
106 "lifecycle".into(),
107 serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
108 );
109 }
110 if let Some(exec) = entry
111 .context
112 .as_ref()
113 .and_then(|context| context.exec.as_ref())
114 {
115 data.insert(
116 "authority".into(),
117 serde_json::json!({
118 "exec": project_exec_context(&entry.profile, exec),
119 }),
120 );
121 }
122
123 let mut ce = Self::new(&source, event_type, serde_json::Value::Object(data));
124 ce.time = entry.timestamp; ce
126 }
127}
128
129fn project_exec_context(profile: &str, exec: &AuditExecContext) -> serde_json::Value {
130 serde_json::json!({
131 "contract_name": exec.contract_name,
132 "target": exec.target,
133 "target_decision": exec.target_decision,
134 "matched_target": exec.matched_target,
135 "authority_profile": exec.authority_profile,
136 "authority_namespace": exec.authority_namespace,
137 "trust_level": exec.trust_level.map(|value| value.as_str()),
138 "access_profile": exec.access_profile.map(|value| value.as_str()),
139 "inherit": exec.inherit.map(|value| value.as_str()),
140 "deny_dangerous_env": exec.deny_dangerous_env,
141 "redact_output": exec.redact_output,
142 "network": exec.network.map(|value| value.as_str()),
143 "allowed_secret_refs": hash_names(profile, &exec.allowed_secrets),
144 "required_secret_refs": hash_names(profile, &exec.required_secrets),
145 "injected_secret_refs": hash_names(profile, &exec.injected_secrets),
146 "missing_required_secret_refs": hash_names(profile, &exec.missing_required_secrets),
147 "dropped_env_names": exec.dropped_env_names,
148 "target_allowed": exec.target_allowed,
149 })
150}
151
152fn hash_names(profile: &str, names: &[String]) -> Vec<String> {
153 let mut out = names
154 .iter()
155 .map(|name| name.trim())
156 .filter(|name| !name.is_empty())
157 .map(|name| key_ref(profile, name))
158 .collect::<Vec<_>>();
159 out.sort();
160 out.dedup();
161 out
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165struct NatsAdapterConfig {
166 url: String,
167 subject: String,
168}
169
170fn nats_adapter_from_env() -> Option<NatsAdapterConfig> {
171 let url = std::env::var("TSAFE_EVENTS_NATS_URL").ok()?;
172 let subject = std::env::var("TSAFE_EVENTS_NATS_SUBJECT").ok()?;
173 let url = url.trim();
174 let subject = subject.trim();
175 if url.is_empty() || subject.is_empty() {
176 return None;
177 }
178 Some(NatsAdapterConfig {
179 url: url.to_string(),
180 subject: subject.to_string(),
181 })
182}
183
184#[cfg(any(test, feature = "nats"))]
185fn publish_nats_with<F>(cfg: &NatsAdapterConfig, line: &str, publish: F) -> Result<(), String>
186where
187 F: FnOnce(&str, &str, &[u8]) -> Result<(), String>,
188{
189 publish(&cfg.url, &cfg.subject, line.as_bytes())
190}
191
192#[cfg(feature = "nats")]
193fn publish_nats(cfg: &NatsAdapterConfig, line: &str) -> Result<(), String> {
194 publish_nats_with(cfg, line, |url, subject, payload| {
195 let connection = nats::Options::new()
196 .with_name("tsafe-events")
197 .connect(url)
198 .map_err(|error| format!("connect failed: {error}"))?;
199 connection
200 .publish(subject, payload)
201 .map_err(|error| format!("publish failed: {error}"))?;
202 connection
203 .flush_timeout(Duration::from_secs(2))
204 .map_err(|error| format!("flush failed: {error}"))?;
205 Ok(())
206 })
207}
208
209#[cfg(not(feature = "nats"))]
210fn warn_nats_feature_disabled() {
211 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
212 WARN_ONCE.call_once(|| {
213 tracing::warn!(
214 "TSAFE_EVENTS_NATS_URL/TSAFE_EVENTS_NATS_SUBJECT were set, but tsafe-core was built without the `nats` feature"
215 );
216 });
217}
218
219pub fn audit_op_to_ce_type(operation: &str) -> String {
224 if let Some(event_type) = classify_operation(operation).event_type {
225 event_type.to_string()
226 } else {
227 format!("com.tsafe.{operation}.v1")
228 }
229}
230
231pub fn emit(event: &CloudEvent) {
265 let line = match serde_json::to_string(event) {
266 Ok(s) => s,
267 Err(_) => return,
268 };
269
270 if let Ok(outbox) = std::env::var("TSAFE_EVENTS_OUTBOX") {
274 if !outbox.is_empty() {
275 if outbox == "-" || outbox.eq_ignore_ascii_case("stderr") {
276 let _ = writeln!(std::io::stderr(), "{line}");
278 } else {
279 let _ = (|| -> std::io::Result<()> {
281 let mut f = std::fs::OpenOptions::new()
282 .create(true)
283 .append(true)
284 .open(&outbox)?;
285 writeln!(f, "{line}")
286 })();
287 }
288 }
289 }
290
291 if let Ok(url) = std::env::var("TSAFE_EVENTS_WEBHOOK_URL") {
296 if url.starts_with("https://") {
297 let line_cloned = line.clone();
298 let url_cloned = url.clone();
299 std::thread::spawn(move || {
300 if let Err(e) = ureq::post(&url_cloned)
301 .set("Content-Type", "application/cloudevents+json")
302 .send_string(&line_cloned)
303 {
304 tracing::warn!(url = %url_cloned, error = %e, "events webhook POST failed");
305 }
306 });
307 }
308 }
309
310 if let Some(cfg) = nats_adapter_from_env() {
314 #[cfg(feature = "nats")]
315 {
316 let line_cloned = line.clone();
317 std::thread::spawn(move || {
318 if let Err(error) = publish_nats(&cfg, &line_cloned) {
319 tracing::warn!(
320 url = %cfg.url,
321 subject = %cfg.subject,
322 error = %error,
323 "events NATS publish failed"
324 );
325 }
326 });
327 }
328
329 #[cfg(not(feature = "nats"))]
330 {
331 let _ = cfg;
332 warn_nats_feature_disabled();
333 }
334 }
335}
336
337#[tracing::instrument(skip(key), fields(profile = %profile, operation = %operation))]
351pub fn emit_event(profile: &str, operation: &str, key: Option<&str>) {
352 let entry = AuditEntry::success(profile, operation, key);
353 emit(&CloudEvent::from_audit(&entry));
354}
355
356#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::audit::{AuditContext, AuditEntry, AuditExecContext};
362 use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
363
364 #[test]
365 fn key_ref_is_deterministic_and_opaque() {
366 let r1 = key_ref("dev", "DB_PASSWORD");
367 let r2 = key_ref("dev", "DB_PASSWORD");
368 assert_eq!(r1, r2, "same inputs must produce same ref");
369 assert!(
370 !r1.contains("DB_PASSWORD"),
371 "key name must not appear in ref"
372 );
373 assert_eq!(r1.len(), 64, "SHA-256 hex is 64 chars");
374 }
375
376 #[test]
377 fn key_ref_differs_across_profiles() {
378 let r_dev = key_ref("dev", "API_KEY");
379 let r_prod = key_ref("prod", "API_KEY");
380 assert_ne!(r_dev, r_prod, "same key in different profiles must differ");
381 }
382
383 #[test]
384 fn from_audit_happy_path() {
385 let entry = AuditEntry::success("main", "set", Some("MY_KEY"));
386 let ce = CloudEvent::from_audit(&entry);
387
388 assert_eq!(ce.specversion, "1.0");
389 assert_eq!(ce.source, "tsafe/main");
390 assert_eq!(ce.event_type, "com.tsafe.vault.secret.set.v1");
391 assert_eq!(ce.datacontenttype, "application/json");
392 assert_eq!(ce.time, entry.timestamp);
393
394 let data = &ce.data;
395 assert_eq!(data["audit_id"], entry.id);
396 assert_eq!(data["operation"], "set");
397 assert_eq!(data["status"], "success");
398
399 let kr = data["key_ref"].as_str().unwrap();
401 assert_eq!(kr.len(), 64);
402 assert!(!kr.contains("MY_KEY"));
403 }
404
405 #[test]
406 fn from_audit_projects_lifecycle_state_for_vault_and_share_ops() {
407 let created = CloudEvent::from_audit(&AuditEntry::success("main", "init", None));
408 assert_eq!(created.data["lifecycle"]["domain"], "vault");
409 assert_eq!(created.data["lifecycle"]["state"], "created");
410
411 let shared = CloudEvent::from_audit(&AuditEntry::success("main", "share-once", None));
412 assert_eq!(shared.data["lifecycle"]["domain"], "share");
413 assert_eq!(shared.data["lifecycle"]["state"], "published");
414 }
415
416 #[test]
417 fn from_audit_projects_lifecycle_state_for_secret_ops() {
418 let written = CloudEvent::from_audit(&AuditEntry::success("main", "set", Some("K")));
419 assert_eq!(written.data["lifecycle"]["domain"], "secret");
420 assert_eq!(written.data["lifecycle"]["state"], "written");
421
422 let accessed = CloudEvent::from_audit(&AuditEntry::success("main", "get", Some("K")));
423 assert_eq!(accessed.data["lifecycle"]["domain"], "secret");
424 assert_eq!(accessed.data["lifecycle"]["state"], "accessed");
425
426 let deleted = CloudEvent::from_audit(&AuditEntry::success("main", "delete", Some("K")));
427 assert_eq!(deleted.data["lifecycle"]["domain"], "secret");
428 assert_eq!(deleted.data["lifecycle"]["state"], "deleted");
429
430 let imported = CloudEvent::from_audit(&AuditEntry::success("main", "import", None));
431 assert_eq!(imported.data["lifecycle"]["domain"], "secret");
432 assert_eq!(imported.data["lifecycle"]["state"], "imported");
433
434 let exported = CloudEvent::from_audit(&AuditEntry::success("main", "export", None));
435 assert_eq!(exported.data["lifecycle"]["domain"], "secret");
436 assert_eq!(exported.data["lifecycle"]["state"], "exported");
437
438 let namespace_copy = CloudEvent::from_audit(&AuditEntry::success("main", "ns-copy", None));
439 assert_eq!(namespace_copy.data["lifecycle"]["domain"], "secret");
440 assert_eq!(namespace_copy.data["lifecycle"]["state"], "written");
441 }
442
443 #[test]
444 fn from_audit_projects_lifecycle_state_for_surface_aliases() {
445 let created = CloudEvent::from_audit(&AuditEntry::success("main", "create", None));
446 assert_eq!(created.data["lifecycle"]["domain"], "vault");
447 assert_eq!(created.data["lifecycle"]["state"], "created");
448 assert_eq!(created.event_type, "com.tsafe.vault.created.v1");
449
450 let team_created = CloudEvent::from_audit(&AuditEntry::success("main", "team-init", None));
451 assert_eq!(team_created.data["lifecycle"]["domain"], "vault");
452 assert_eq!(team_created.data["lifecycle"]["state"], "created");
453 assert_eq!(team_created.event_type, "com.tsafe.vault.created.v1");
454
455 let namespace_move = CloudEvent::from_audit(&AuditEntry::success("main", "ns-move", None));
456 assert_eq!(namespace_move.data["lifecycle"]["domain"], "vault");
457 assert_eq!(namespace_move.data["lifecycle"]["state"], "secret_moved");
458 assert_eq!(namespace_move.event_type, "com.tsafe.ns-move.v1");
459 }
460
461 #[test]
462 fn from_audit_projects_lifecycle_state_for_policy_and_helper_ops() {
463 let policy_set = CloudEvent::from_audit(&AuditEntry::success("main", "policy-set", None));
464 assert_eq!(policy_set.data["lifecycle"]["domain"], "policy");
465 assert_eq!(policy_set.data["lifecycle"]["state"], "set");
466 assert_eq!(policy_set.event_type, "com.tsafe.policy-set.v1");
467
468 let rotate_due = CloudEvent::from_audit(&AuditEntry::success("main", "rotate-due", None));
469 assert_eq!(rotate_due.data["lifecycle"]["domain"], "policy");
470 assert_eq!(rotate_due.data["lifecycle"]["state"], "due_checked");
471 assert_eq!(rotate_due.event_type, "com.tsafe.secret.rotation_due.v1");
472
473 let helper_get =
474 CloudEvent::from_audit(&AuditEntry::success("main", "credential-helper-get", None));
475 assert_eq!(helper_get.data["lifecycle"]["domain"], "credential_helper");
476 assert_eq!(helper_get.data["lifecycle"]["state"], "accessed");
477 assert_eq!(helper_get.event_type, "com.tsafe.credential-helper-get.v1");
478
479 let helper_erase = CloudEvent::from_audit(&AuditEntry::success(
480 "main",
481 "credential-helper-erase",
482 None,
483 ));
484 assert_eq!(
485 helper_erase.data["lifecycle"]["domain"],
486 "credential_helper"
487 );
488 assert_eq!(helper_erase.data["lifecycle"]["state"], "erased");
489 assert_eq!(
490 helper_erase.event_type,
491 "com.tsafe.credential-helper-erase.v1"
492 );
493 }
494
495 #[test]
496 fn from_audit_projects_lifecycle_state_for_team_membership_ops() {
497 let added = CloudEvent::from_audit(&AuditEntry::success("main", "team-add-member", None));
498 assert_eq!(added.data["lifecycle"]["domain"], "team");
499 assert_eq!(added.data["lifecycle"]["state"], "member_added");
500 assert_eq!(added.event_type, "com.tsafe.team-add-member.v1");
501
502 let removed =
503 CloudEvent::from_audit(&AuditEntry::success("main", "team-remove-member", None));
504 assert_eq!(removed.data["lifecycle"]["domain"], "team");
505 assert_eq!(removed.data["lifecycle"]["state"], "member_removed");
506 assert_eq!(removed.event_type, "com.tsafe.team-remove-member.v1");
507 }
508
509 #[test]
510 fn from_audit_projects_lifecycle_state_for_session_and_sync_ops() {
511 let unlocked = CloudEvent::from_audit(&AuditEntry::success("main", "unlock", None));
512 assert_eq!(unlocked.data["lifecycle"]["domain"], "session");
513 assert_eq!(unlocked.data["lifecycle"]["state"], "unlocked");
514
515 let pulled = CloudEvent::from_audit(&AuditEntry::success("main", "kv-pull", None));
516 assert_eq!(pulled.data["lifecycle"]["domain"], "sync");
517 assert_eq!(pulled.data["lifecycle"]["state"], "pull_completed");
518
519 let merged = CloudEvent::from_audit(&AuditEntry::success("main", "sync", None));
520 assert_eq!(merged.data["lifecycle"]["domain"], "sync");
521 assert_eq!(merged.data["lifecycle"]["state"], "merged");
522 }
523
524 #[test]
525 fn from_audit_no_key() {
526 let entry = AuditEntry::success("main", "export", None);
527 let ce = CloudEvent::from_audit(&entry);
528 assert_eq!(ce.data["key_ref"], serde_json::Value::Null);
529 }
530
531 #[test]
532 fn from_audit_projects_exec_authority_without_plaintext_secret_names() {
533 let contract = AuthorityContract {
534 name: "deploy".into(),
535 profile: Some("work".into()),
536 namespace: Some("infra".into()),
537 access_profile: crate::rbac::RbacProfile::ReadOnly,
538 allow_all_secrets: false,
539 allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
540 required_secrets: vec!["DB_PASSWORD".into()],
541 allowed_targets: vec!["terraform".into()],
542 trust: AuthorityTrust::Hardened,
543 network: AuthorityNetworkPolicy::Restricted,
544 };
545 let entry = AuditEntry::success("dev", "exec", None).with_context(AuditContext::from_exec(
546 AuditExecContext::from_contract(&contract)
547 .with_target("/usr/bin/terraform")
548 .with_injected_secrets(["DB_PASSWORD"])
549 .with_missing_required_secrets(["API_KEY"])
550 .with_dropped_env_names(["OPENAI_API_KEY"])
551 .with_target_evaluation(&contract.evaluate_target(Some("/usr/bin/terraform"))),
552 ));
553
554 let ce = CloudEvent::from_audit(&entry);
555 let exec = &ce.data["authority"]["exec"];
556 assert_eq!(exec["contract_name"], "deploy");
557 assert_eq!(exec["target_decision"], "allowed_basename");
558 assert_eq!(exec["matched_target"], "terraform");
559 assert_eq!(exec["trust_level"], "hardened");
560 assert_eq!(exec["access_profile"], "read_only");
561 assert_eq!(exec["inherit"], "minimal");
562 assert_eq!(exec["network"], "restricted");
563 assert_eq!(
564 exec["dropped_env_names"],
565 serde_json::json!(["OPENAI_API_KEY"])
566 );
567 assert_eq!(exec["allowed_secret_refs"].as_array().unwrap().len(), 2);
568 let encoded = serde_json::to_string(&ce).unwrap();
569 assert!(!encoded.contains("DB_PASSWORD"));
570 assert!(!encoded.contains(r#""API_KEY""#));
571 }
572
573 #[test]
574 fn audit_op_mapping_coverage() {
575 for op in &[
577 "set",
578 "delete",
579 "get",
580 "init",
581 "rotate",
582 "export",
583 "import",
584 "exec",
585 "create",
586 "team-init",
587 "team-add-member",
588 "team-remove-member",
589 "policy-set",
590 "policy-remove",
591 "unlock",
592 "kv-pull",
593 "vault-pull",
594 "op-pull",
595 "pull",
596 "ns-copy",
597 "ns-move",
598 "credential-helper-get",
599 "credential-helper-store",
600 "credential-helper-erase",
601 "share-once",
602 "receive-once",
603 "snap",
604 "snap-receive",
605 "rotate-due",
606 ] {
607 let t = audit_op_to_ce_type(op);
608 assert!(t.starts_with("com.tsafe."), "bad type for op '{op}': {t}");
609 assert!(
610 t.ends_with(".v1"),
611 "type must end with .v1 for op '{op}': {t}"
612 );
613 }
614 }
615
616 #[test]
617 fn unknown_op_gets_fallback_type() {
618 let t = audit_op_to_ce_type("custom-op");
619 assert_eq!(t, "com.tsafe.custom-op.v1");
620 }
621
622 #[test]
623 fn snapshot_restore_keeps_fallback_event_type() {
624 let t = audit_op_to_ce_type("snapshot-restore");
625 assert_eq!(t, "com.tsafe.snapshot-restore.v1");
626 }
627
628 #[test]
629 fn cloud_event_serialises_correctly() {
630 let entry = AuditEntry::success("dev", "set", Some("API_KEY"));
631 let ce = CloudEvent::from_audit(&entry);
632 let json = serde_json::to_string(&ce).unwrap();
633 assert!(json.contains(r#""specversion":"1.0""#));
634 assert!(json.contains(r#""type":"com.tsafe.vault.secret.set.v1""#));
635 assert!(json.contains(r#""datacontenttype":"application/json""#));
636 assert!(
638 !json.contains("API_KEY"),
639 "plaintext key name must not appear in event JSON"
640 );
641 }
642
643 #[test]
644 fn emit_to_file_outbox() {
645 use tempfile::tempdir;
646 let dir = tempdir().unwrap();
647 let outbox = dir.path().join("events.jsonl");
648
649 temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
650 emit_event("dev", "set", Some("SECRET_KEY"));
651 emit_event("dev", "delete", Some("OLD_KEY"));
652 });
653
654 let content = std::fs::read_to_string(&outbox).unwrap();
655 let lines: Vec<&str> = content.lines().collect();
656 assert_eq!(lines.len(), 2, "should have two JSONL lines");
657
658 for line in &lines {
659 let v: serde_json::Value = serde_json::from_str(line).unwrap();
660 assert_eq!(v["specversion"], "1.0");
661 assert_eq!(v["datacontenttype"], "application/json");
662 let source = v["source"].as_str().unwrap();
663 assert!(source.starts_with("tsafe/dev"));
664 assert!(!line.contains("SECRET_KEY"), "key name leaked into event");
666 assert!(!line.contains("OLD_KEY"), "key name leaked into event");
667 }
668 }
669
670 #[test]
671 fn emit_noop_when_no_env_vars() {
672 temp_env::with_vars(
674 [
675 ("TSAFE_EVENTS_OUTBOX", None::<&str>),
676 ("TSAFE_EVENTS_WEBHOOK_URL", None),
677 ("TSAFE_EVENTS_NATS_URL", None),
678 ("TSAFE_EVENTS_NATS_SUBJECT", None),
679 ],
680 || {
681 emit_event("dev", "get", Some("ANY_KEY")); },
683 );
684 }
685
686 #[test]
687 fn nats_adapter_requires_both_url_and_subject() {
688 temp_env::with_vars(
689 [
690 ("TSAFE_EVENTS_NATS_URL", Some("nats://127.0.0.1:4222")),
691 ("TSAFE_EVENTS_NATS_SUBJECT", None),
692 ],
693 || assert!(nats_adapter_from_env().is_none()),
694 );
695
696 temp_env::with_vars(
697 [
698 ("TSAFE_EVENTS_NATS_URL", Some(" ")),
699 ("TSAFE_EVENTS_NATS_SUBJECT", Some("events.tsafe")),
700 ],
701 || assert!(nats_adapter_from_env().is_none()),
702 );
703 }
704
705 #[test]
706 fn nats_adapter_reads_trimmed_env_values() {
707 temp_env::with_vars(
708 [
709 ("TSAFE_EVENTS_NATS_URL", Some(" nats://127.0.0.1:4222 ")),
710 ("TSAFE_EVENTS_NATS_SUBJECT", Some(" events.tsafe ")),
711 ],
712 || {
713 let cfg = nats_adapter_from_env().expect("expected NATS adapter config");
714 assert_eq!(cfg.url, "nats://127.0.0.1:4222");
715 assert_eq!(cfg.subject, "events.tsafe");
716 },
717 );
718 }
719
720 #[test]
721 fn publish_nats_with_uses_expected_target_and_payload() {
722 let cfg = NatsAdapterConfig {
723 url: "nats://127.0.0.1:4222".into(),
724 subject: "events.tsafe".into(),
725 };
726 let mut seen = None;
727 publish_nats_with(
728 &cfg,
729 "{\"type\":\"com.tsafe.test.v1\"}",
730 |url, subject, payload| {
731 seen = Some((
732 url.to_string(),
733 subject.to_string(),
734 String::from_utf8(payload.to_vec()).unwrap(),
735 ));
736 Ok(())
737 },
738 )
739 .unwrap();
740
741 assert_eq!(
742 seen,
743 Some((
744 "nats://127.0.0.1:4222".into(),
745 "events.tsafe".into(),
746 "{\"type\":\"com.tsafe.test.v1\"}".into(),
747 ))
748 );
749 }
750
751 #[test]
752 fn emit_event_convenience_wrapper() {
753 use tempfile::tempdir;
754 let dir = tempdir().unwrap();
755 let outbox = dir.path().join("e.jsonl");
756
757 temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
758 emit_event("main", "init", None);
759 });
760
761 let content = std::fs::read_to_string(&outbox).unwrap();
762 let line = content
763 .lines()
764 .next()
765 .expect("expected one JSONL line from emit");
766 let v: serde_json::Value = serde_json::from_str(line).unwrap();
767 assert_eq!(v["type"], "com.tsafe.vault.created.v1");
768 }
769
770 #[test]
778 fn no_plaintext_secret_value_in_event_payload() {
779 use tempfile::tempdir;
780 let dir = tempdir().unwrap();
781 let outbox = dir.path().join("no-leak.jsonl");
782
783 let sensitive_key = "PROD_DB_PASSWORD";
785 let sensitive_value = "super-secret-plaintext-value-12345";
786
787 temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
790 emit_event("prod", "set", Some(sensitive_key));
791 emit_event("prod", "get", Some(sensitive_key));
792 emit_event("prod", "delete", Some(sensitive_key));
793 });
794
795 let content = std::fs::read_to_string(&outbox).unwrap();
796 assert!(
797 !content.contains(sensitive_key),
798 "plaintext key name must not appear in any emitted event (adapter: outbox file)"
799 );
800 assert!(
801 !content.contains(sensitive_value),
802 "plaintext secret value must not appear in any emitted event (adapter: outbox file)"
803 );
804
805 for line in content.lines() {
807 let v: serde_json::Value =
808 serde_json::from_str(line).expect("each emitted line must be valid JSON");
809 assert_eq!(v["specversion"], "1.0");
810 }
811 }
812
813 #[test]
814 fn no_plaintext_leak_in_exec_authority_event() {
815 use crate::audit::{AuditContext, AuditEntry, AuditExecContext};
816 use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
817
818 let contract = AuthorityContract {
819 name: "ci-deploy".into(),
820 profile: Some("prod".into()),
821 namespace: Some("infra".into()),
822 access_profile: crate::rbac::RbacProfile::ReadOnly,
823 allow_all_secrets: false,
824 allowed_secrets: vec!["DATABASE_URL".into(), "API_SECRET".into()],
825 required_secrets: vec!["DATABASE_URL".into()],
826 allowed_targets: vec!["deploy.sh".into()],
827 trust: AuthorityTrust::Hardened,
828 network: AuthorityNetworkPolicy::Restricted,
829 };
830
831 let entry =
832 AuditEntry::success("prod", "exec", None).with_context(AuditContext::from_exec(
833 AuditExecContext::from_contract(&contract)
834 .with_target("/scripts/deploy.sh")
835 .with_injected_secrets(["DATABASE_URL"])
836 .with_missing_required_secrets(["API_SECRET"])
837 .with_dropped_env_names(["OPENAI_API_KEY"])
838 .with_target_evaluation(&contract.evaluate_target(Some("/scripts/deploy.sh"))),
839 ));
840
841 let ce = CloudEvent::from_audit(&entry);
842 let serialised = serde_json::to_string(&ce).unwrap();
843
844 assert!(
846 !serialised.contains("DATABASE_URL"),
847 "plaintext key name DATABASE_URL must not appear in exec authority event"
848 );
849 assert!(
850 !serialised.contains("API_SECRET"),
851 "plaintext key name API_SECRET must not appear in exec authority event"
852 );
853
854 let auth = &ce.data["authority"]["exec"];
856 assert!(
857 !auth.is_null(),
858 "authority.exec must be present for exec events"
859 );
860 let refs = auth["allowed_secret_refs"].as_array().unwrap();
861 assert_eq!(refs.len(), 2, "two allowed secrets should produce two refs");
862 for r in refs {
863 let s = r.as_str().unwrap();
864 assert_eq!(s.len(), 64, "each ref must be a 64-char SHA-256 hex string");
865 }
866 }
867}