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 allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
539 required_secrets: vec!["DB_PASSWORD".into()],
540 allowed_targets: vec!["terraform".into()],
541 trust: AuthorityTrust::Hardened,
542 network: AuthorityNetworkPolicy::Restricted,
543 };
544 let entry = AuditEntry::success("dev", "exec", None).with_context(AuditContext::from_exec(
545 AuditExecContext::from_contract(&contract)
546 .with_target("/usr/bin/terraform")
547 .with_injected_secrets(["DB_PASSWORD"])
548 .with_missing_required_secrets(["API_KEY"])
549 .with_dropped_env_names(["OPENAI_API_KEY"])
550 .with_target_evaluation(&contract.evaluate_target(Some("/usr/bin/terraform"))),
551 ));
552
553 let ce = CloudEvent::from_audit(&entry);
554 let exec = &ce.data["authority"]["exec"];
555 assert_eq!(exec["contract_name"], "deploy");
556 assert_eq!(exec["target_decision"], "allowed_basename");
557 assert_eq!(exec["matched_target"], "terraform");
558 assert_eq!(exec["trust_level"], "hardened");
559 assert_eq!(exec["access_profile"], "read_only");
560 assert_eq!(exec["inherit"], "minimal");
561 assert_eq!(exec["network"], "restricted");
562 assert_eq!(
563 exec["dropped_env_names"],
564 serde_json::json!(["OPENAI_API_KEY"])
565 );
566 assert_eq!(exec["allowed_secret_refs"].as_array().unwrap().len(), 2);
567 let encoded = serde_json::to_string(&ce).unwrap();
568 assert!(!encoded.contains("DB_PASSWORD"));
569 assert!(!encoded.contains(r#""API_KEY""#));
570 }
571
572 #[test]
573 fn audit_op_mapping_coverage() {
574 for op in &[
576 "set",
577 "delete",
578 "get",
579 "init",
580 "rotate",
581 "export",
582 "import",
583 "exec",
584 "create",
585 "team-init",
586 "team-add-member",
587 "team-remove-member",
588 "policy-set",
589 "policy-remove",
590 "unlock",
591 "kv-pull",
592 "vault-pull",
593 "op-pull",
594 "pull",
595 "ns-copy",
596 "ns-move",
597 "credential-helper-get",
598 "credential-helper-store",
599 "credential-helper-erase",
600 "share-once",
601 "receive-once",
602 "snap",
603 "snap-receive",
604 "rotate-due",
605 ] {
606 let t = audit_op_to_ce_type(op);
607 assert!(t.starts_with("com.tsafe."), "bad type for op '{op}': {t}");
608 assert!(
609 t.ends_with(".v1"),
610 "type must end with .v1 for op '{op}': {t}"
611 );
612 }
613 }
614
615 #[test]
616 fn unknown_op_gets_fallback_type() {
617 let t = audit_op_to_ce_type("custom-op");
618 assert_eq!(t, "com.tsafe.custom-op.v1");
619 }
620
621 #[test]
622 fn snapshot_restore_keeps_fallback_event_type() {
623 let t = audit_op_to_ce_type("snapshot-restore");
624 assert_eq!(t, "com.tsafe.snapshot-restore.v1");
625 }
626
627 #[test]
628 fn cloud_event_serialises_correctly() {
629 let entry = AuditEntry::success("dev", "set", Some("API_KEY"));
630 let ce = CloudEvent::from_audit(&entry);
631 let json = serde_json::to_string(&ce).unwrap();
632 assert!(json.contains(r#""specversion":"1.0""#));
633 assert!(json.contains(r#""type":"com.tsafe.vault.secret.set.v1""#));
634 assert!(json.contains(r#""datacontenttype":"application/json""#));
635 assert!(
637 !json.contains("API_KEY"),
638 "plaintext key name must not appear in event JSON"
639 );
640 }
641
642 #[test]
643 fn emit_to_file_outbox() {
644 use tempfile::tempdir;
645 let dir = tempdir().unwrap();
646 let outbox = dir.path().join("events.jsonl");
647
648 temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
649 emit_event("dev", "set", Some("SECRET_KEY"));
650 emit_event("dev", "delete", Some("OLD_KEY"));
651 });
652
653 let content = std::fs::read_to_string(&outbox).unwrap();
654 let lines: Vec<&str> = content.lines().collect();
655 assert_eq!(lines.len(), 2, "should have two JSONL lines");
656
657 for line in &lines {
658 let v: serde_json::Value = serde_json::from_str(line).unwrap();
659 assert_eq!(v["specversion"], "1.0");
660 assert_eq!(v["datacontenttype"], "application/json");
661 let source = v["source"].as_str().unwrap();
662 assert!(source.starts_with("tsafe/dev"));
663 assert!(!line.contains("SECRET_KEY"), "key name leaked into event");
665 assert!(!line.contains("OLD_KEY"), "key name leaked into event");
666 }
667 }
668
669 #[test]
670 fn emit_noop_when_no_env_vars() {
671 temp_env::with_vars(
673 [
674 ("TSAFE_EVENTS_OUTBOX", None::<&str>),
675 ("TSAFE_EVENTS_WEBHOOK_URL", None),
676 ("TSAFE_EVENTS_NATS_URL", None),
677 ("TSAFE_EVENTS_NATS_SUBJECT", None),
678 ],
679 || {
680 emit_event("dev", "get", Some("ANY_KEY")); },
682 );
683 }
684
685 #[test]
686 fn nats_adapter_requires_both_url_and_subject() {
687 temp_env::with_vars(
688 [
689 ("TSAFE_EVENTS_NATS_URL", Some("nats://127.0.0.1:4222")),
690 ("TSAFE_EVENTS_NATS_SUBJECT", None),
691 ],
692 || assert!(nats_adapter_from_env().is_none()),
693 );
694
695 temp_env::with_vars(
696 [
697 ("TSAFE_EVENTS_NATS_URL", Some(" ")),
698 ("TSAFE_EVENTS_NATS_SUBJECT", Some("events.tsafe")),
699 ],
700 || assert!(nats_adapter_from_env().is_none()),
701 );
702 }
703
704 #[test]
705 fn nats_adapter_reads_trimmed_env_values() {
706 temp_env::with_vars(
707 [
708 ("TSAFE_EVENTS_NATS_URL", Some(" nats://127.0.0.1:4222 ")),
709 ("TSAFE_EVENTS_NATS_SUBJECT", Some(" events.tsafe ")),
710 ],
711 || {
712 let cfg = nats_adapter_from_env().expect("expected NATS adapter config");
713 assert_eq!(cfg.url, "nats://127.0.0.1:4222");
714 assert_eq!(cfg.subject, "events.tsafe");
715 },
716 );
717 }
718
719 #[test]
720 fn publish_nats_with_uses_expected_target_and_payload() {
721 let cfg = NatsAdapterConfig {
722 url: "nats://127.0.0.1:4222".into(),
723 subject: "events.tsafe".into(),
724 };
725 let mut seen = None;
726 publish_nats_with(
727 &cfg,
728 "{\"type\":\"com.tsafe.test.v1\"}",
729 |url, subject, payload| {
730 seen = Some((
731 url.to_string(),
732 subject.to_string(),
733 String::from_utf8(payload.to_vec()).unwrap(),
734 ));
735 Ok(())
736 },
737 )
738 .unwrap();
739
740 assert_eq!(
741 seen,
742 Some((
743 "nats://127.0.0.1:4222".into(),
744 "events.tsafe".into(),
745 "{\"type\":\"com.tsafe.test.v1\"}".into(),
746 ))
747 );
748 }
749
750 #[test]
751 fn emit_event_convenience_wrapper() {
752 use tempfile::tempdir;
753 let dir = tempdir().unwrap();
754 let outbox = dir.path().join("e.jsonl");
755
756 temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
757 emit_event("main", "init", None);
758 });
759
760 let content = std::fs::read_to_string(&outbox).unwrap();
761 let line = content
762 .lines()
763 .next()
764 .expect("expected one JSONL line from emit");
765 let v: serde_json::Value = serde_json::from_str(line).unwrap();
766 assert_eq!(v["type"], "com.tsafe.vault.created.v1");
767 }
768
769 #[test]
777 fn no_plaintext_secret_value_in_event_payload() {
778 use tempfile::tempdir;
779 let dir = tempdir().unwrap();
780 let outbox = dir.path().join("no-leak.jsonl");
781
782 let sensitive_key = "PROD_DB_PASSWORD";
784 let sensitive_value = "super-secret-plaintext-value-12345";
785
786 temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
789 emit_event("prod", "set", Some(sensitive_key));
790 emit_event("prod", "get", Some(sensitive_key));
791 emit_event("prod", "delete", Some(sensitive_key));
792 });
793
794 let content = std::fs::read_to_string(&outbox).unwrap();
795 assert!(
796 !content.contains(sensitive_key),
797 "plaintext key name must not appear in any emitted event (adapter: outbox file)"
798 );
799 assert!(
800 !content.contains(sensitive_value),
801 "plaintext secret value must not appear in any emitted event (adapter: outbox file)"
802 );
803
804 for line in content.lines() {
806 let v: serde_json::Value =
807 serde_json::from_str(line).expect("each emitted line must be valid JSON");
808 assert_eq!(v["specversion"], "1.0");
809 }
810 }
811
812 #[test]
813 fn no_plaintext_leak_in_exec_authority_event() {
814 use crate::audit::{AuditContext, AuditEntry, AuditExecContext};
815 use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
816
817 let contract = AuthorityContract {
818 name: "ci-deploy".into(),
819 profile: Some("prod".into()),
820 namespace: Some("infra".into()),
821 access_profile: crate::rbac::RbacProfile::ReadOnly,
822 allowed_secrets: vec!["DATABASE_URL".into(), "API_SECRET".into()],
823 required_secrets: vec!["DATABASE_URL".into()],
824 allowed_targets: vec!["deploy.sh".into()],
825 trust: AuthorityTrust::Hardened,
826 network: AuthorityNetworkPolicy::Restricted,
827 };
828
829 let entry =
830 AuditEntry::success("prod", "exec", None).with_context(AuditContext::from_exec(
831 AuditExecContext::from_contract(&contract)
832 .with_target("/scripts/deploy.sh")
833 .with_injected_secrets(["DATABASE_URL"])
834 .with_missing_required_secrets(["API_SECRET"])
835 .with_dropped_env_names(["OPENAI_API_KEY"])
836 .with_target_evaluation(&contract.evaluate_target(Some("/scripts/deploy.sh"))),
837 ));
838
839 let ce = CloudEvent::from_audit(&entry);
840 let serialised = serde_json::to_string(&ce).unwrap();
841
842 assert!(
844 !serialised.contains("DATABASE_URL"),
845 "plaintext key name DATABASE_URL must not appear in exec authority event"
846 );
847 assert!(
848 !serialised.contains("API_SECRET"),
849 "plaintext key name API_SECRET must not appear in exec authority event"
850 );
851
852 let auth = &ce.data["authority"]["exec"];
854 assert!(
855 !auth.is_null(),
856 "authority.exec must be present for exec events"
857 );
858 let refs = auth["allowed_secret_refs"].as_array().unwrap();
859 assert_eq!(refs.len(), 2, "two allowed secrets should produce two refs");
860 for r in refs {
861 let s = r.as_str().unwrap();
862 assert_eq!(s.len(), 64, "each ref must be a 64-char SHA-256 hex string");
863 }
864 }
865}