Skip to main content

tsafe_core/
events.rs

1//! CloudEvents 1.0 projection layer for tsafe.
2//!
3//! Converts internal audit entries and vault operations into spec-compliant
4//! `application/cloudevents+json` envelopes.  The vault file remains the
5//! authoritative source of truth; events are projections only.
6//!
7//! # Security rule
8//! Secret *values* are never included in events.  When a key name appears it
9//! is replaced with an opaque `key_ref` (SHA-256 of `profile:key`) so that
10//! event payloads can safely traverse external sinks (webhooks, NATS, log
11//! aggregators) without leaking metadata about what secrets a vault contains.
12
13use 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
25// ── key_ref ──────────────────────────────────────────────────────────────────
26
27/// Return a deterministic, opaque reference for a secret key.
28///
29/// `key_ref = hex(SHA-256(profile ":" key))`
30///
31/// The profile is mixed in so the same key name in two different vaults
32/// produces different refs, preventing cross-vault correlation.
33pub 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// ── CloudEvent envelope ───────────────────────────────────────────────────────
42
43/// Minimal CloudEvents 1.0 envelope.
44///
45/// Serialised as `application/cloudevents+json` (one envelope per line in JSONL
46/// mode).  Only the mandatory attributes plus `datacontenttype` are included;
47/// extension attributes are not needed for Phase 1.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CloudEvent {
50    /// Always `"1.0"`.
51    pub specversion: String,
52    /// UUIDv4 — unique per event occurrence.
53    pub id: String,
54    /// `tsafe/<profile>` — identifies the vault that produced this event.
55    pub source: String,
56    /// Reverse-DNS type identifier, e.g. `com.tsafe.vault.secret.set.v1`.
57    #[serde(rename = "type")]
58    pub event_type: String,
59    /// ISO-8601 UTC timestamp of the event.
60    pub time: DateTime<Utc>,
61    /// Always `"application/json"`.
62    pub datacontenttype: String,
63    /// Event-specific payload.  Never contains plaintext secret values.
64    pub data: serde_json::Value,
65}
66
67impl CloudEvent {
68    /// Construct a new envelope with a fresh `id` and `time = now`.
69    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    /// Project an [`AuditEntry`] into a CloudEvent.
82    ///
83    /// The `key` field from the audit entry is replaced with an opaque
84    /// `key_ref` (see [`key_ref`]).  The original audit timestamp is
85    /// preserved as the event `time` so replay is accurate.
86    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; // use original audit timestamp, not now()
125        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
219// ── operation → CloudEvents type mapping ─────────────────────────────────────
220
221/// Map a vault operation name (as recorded in the audit log) to a CloudEvents
222/// `type` string following the `com.tsafe.*.v1` taxonomy.
223pub 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
231// ── event adapters ────────────────────────────────────────────────────────────
232
233/// Emit a [`CloudEvent`] through all configured adapters.
234///
235/// # Durability contract (ADR-023)
236///
237/// All three adapters are **at-most-once delivery, fire-and-forget**.
238/// A failure in any adapter is silently logged (warning only) so vault
239/// operations are never blocked. No adapter retries on failure. No adapter
240/// provides acknowledgement or deduplication. Event loss is accepted for fast
241/// CLI commands — network partitions, crashes, and process exits will cause
242/// loss without alerting the operator.
243///
244/// **The per-profile `audit.jsonl` file is the authoritative record.**
245/// Events are a secondary projection channel. Operators who need guaranteed
246/// delivery must consume the audit log directly or implement their own
247/// reconciliation layer.
248///
249/// # No-plaintext-secret invariant
250///
251/// Event payloads must never contain plaintext secret values or plaintext key
252/// names. This invariant is enforced at the [`CloudEvent::from_audit`]
253/// projection layer before any adapter serialises the payload. Secret names
254/// are replaced with an opaque `key_ref = hex(SHA-256(profile:key))` hash.
255///
256/// | Env var | Behaviour |
257/// |---------|-----------|
258/// | `TSAFE_EVENTS_OUTBOX=<path>` | Append one JSONL line to the file |
259/// | `TSAFE_EVENTS_OUTBOX=-` | Write one JSONL line to stderr |
260/// | `TSAFE_EVENTS_WEBHOOK_URL=https://…` | HTTP POST in a background thread |
261/// | `TSAFE_EVENTS_NATS_URL=nats://…` + `TSAFE_EVENTS_NATS_SUBJECT=…` | Publish to NATS when built with the `nats` feature |
262///
263/// If no env vars are set this function is a noop.
264pub fn emit(event: &CloudEvent) {
265    let line = match serde_json::to_string(event) {
266        Ok(s) => s,
267        Err(_) => return,
268    };
269
270    // ── outbox adapter ──────────────────────────────────────────────────────
271    // At-most-once delivery. Fire-and-forget. Audit log is authoritative.
272    // Event loss is accepted on fast CLI commands.
273    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                // Stderr adapter — safe to mix with stdout commands.
277                let _ = writeln!(std::io::stderr(), "{line}");
278            } else {
279                // File adapter — JSONL append.
280                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    // ── HTTP webhook adapter (fire-and-forget) ──────────────────────────────
292    // At-most-once delivery. Fire-and-forget. Audit log is authoritative.
293    // Event loss is accepted on fast CLI commands. HTTPS only — never sends
294    // events over plain HTTP.
295    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    // ── NATS adapter (fire-and-forget, feature-gated) ──────────────────────
311    // At-most-once delivery. Fire-and-forget. Audit log is authoritative.
312    // Event loss is accepted on fast CLI commands.
313    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/// Convenience wrapper: build a [`CloudEvent`] from raw audit parts and emit it.
338///
339/// Constructs an [`AuditEntry`] with `now` as timestamp, projects it to a
340/// `CloudEvent`, and calls [`emit`].  This is the one-liner callers use at
341/// workflow boundaries.
342///
343/// # Span instrumentation (ADR-024)
344///
345/// The span records `profile` and `operation` only.  The `key` parameter is
346/// explicitly skipped — it contains the plaintext secret-key name, which must
347/// never appear in span attributes, log fields, or OTel exports.  The emitted
348/// event payload already replaces the key name with an opaque `key_ref`
349/// (SHA-256) before any adapter serialises it.
350#[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// ── tests ─────────────────────────────────────────────────────────────────────
357
358#[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        // key_ref must be present and opaque
400        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        // All well-known operations must produce a com.tsafe. prefix.
575        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        // Secret key name must not appear anywhere in the serialised event.
636        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            // key name must not appear in the event
664            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        // Ensure neither outbox nor webhook env vars are set during this test.
672        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")); // should be a silent noop
681            },
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    // ── Task 6.2: no-plaintext-leak contract test ─────────────────────────
770    //
771    // This test verifies the no-plaintext-secret invariant (ADR-023) for the
772    // outbox adapter. Secret values and plaintext key names must never appear
773    // in any emitted event payload regardless of which adapter handles the
774    // event.
775
776    #[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        // A representative set of ops that cover different lifecycle domains.
783        let sensitive_key = "PROD_DB_PASSWORD";
784        let sensitive_value = "super-secret-plaintext-value-12345";
785
786        // Simulate a secret being set — key name must be absent, value is
787        // never passed to the event layer at all.
788        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        // All lines must parse as valid JSON with a specversion field.
805        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        // Plaintext secret names must not appear anywhere in the payload.
843        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        // The authority sub-object must be present and use opaque refs.
853        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}