tsafe-core 1.0.13

Core runtime engine for tsafe — encrypted credential storage, process injection contracts, audit log, RBAC
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! CloudEvents schema roundtrip tests (task C7).
//!
//! For each event family defined in `contracts/events/` this module:
//!   1. Constructs a real event using `CloudEvent::from_audit()`.
//!   2. Serialises it to `serde_json::Value`.
//!   3. Validates the value against the published JSON schema.
//!
//! ## `authority.exec` shape (all six schemas) — resolved 2026-05-16
//!
//! The JSON schemas previously defined `authority.exec` as an object with
//! required fields `command`, `pid`, and `authority_profile` (with
//! `additionalProperties: false`). The runtime implementation in
//! `CloudEvent::from_audit()` / `project_exec_context()` emits the
//! authority-contract domain model: `contract_name`, `target`, `trust_level`,
//! `network`, `allowed_secret_refs`, `required_secret_refs`, etc.
//!
//! The schemas have been updated (task C7-fix) to match the runtime's actual
//! output. All six published schemas now accept the full exec-context shape.
//! The `authority_exec_shape_matches_runtime` test below confirms that an event
//! WITH an exec-context authority block passes schema validation end-to-end.
//!
//! ## `browser` event family
//!
//! There is no operation string in `classify_operation()` that maps to
//! `com.tsafe.browser.phishing_blocked.v1`. The browser event type can only be
//! constructed via `CloudEvent::new()` with a hand-crafted payload — there is no
//! audit-log operation that routes through the browser schema. A `browser_event_gap`
//! test documents this and validates a hand-constructed minimal payload to confirm
//! the schema itself is valid and parseable.
//!
//! ## ADR-024 no-plaintext-secret invariant (C7.2)
//!
//! ADR-022 §5 states the invariant is currently "convention only, not enforced by
//! the Rust type system". The mechanical enforcement in this module works by:
//!   - serialising the full event JSON to a string
//!   - asserting no sensitive string appears anywhere in the serialised form
//!
//! This covers the outbox adapter path. The tracing/OTel path has no span
//! instrumentation in `events.rs` that emits secret values (the module uses
//! `tracing::warn!` only for adapter failures, never for event payloads), so
//! there are no span attributes to assert against. The `no_plaintext_in_spans_gap`
//! test documents this and confirms the `warn!` call sites are adapter-error-only.

use tsafe_core::{
    audit::AuditEntry,
    events::{key_ref, CloudEvent},
};

// ── schema helpers ────────────────────────────────────────────────────────────

/// Load a schema file relative to the workspace root.
///
/// `include_str!` paths are relative to the source file; since this test file
/// lives in `crates/tsafe-core/tests/`, walking up three levels reaches the
/// workspace root where `contracts/` lives.
macro_rules! schema_str {
    ($path:literal) => {
        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../", $path))
    };
}

fn validator_for_schema(schema_text: &str) -> jsonschema::Validator {
    let schema_value: serde_json::Value =
        serde_json::from_str(schema_text).expect("schema file must be valid JSON");
    jsonschema::validator_for(&schema_value).expect("schema must be a valid JSON Schema")
}

fn assert_validates(validator: &jsonschema::Validator, event: &CloudEvent, label: &str) {
    let value = serde_json::to_value(event).expect("CloudEvent must serialise to JSON");
    let errors: Vec<String> = validator
        .iter_errors(&value)
        .map(|e| format!("{e}"))
        .collect();
    assert!(
        errors.is_empty(),
        "{label}: schema validation failed with {} error(s):\n{}",
        errors.len(),
        errors.join("\n")
    );
}

// ── C7.1 — schema roundtrip tests ────────────────────────────────────────────

/// secret: `set` operation → `com.tsafe.vault.secret.set.v1`
#[test]
fn secret_set_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("prod", "set", Some("DB_PASSWORD"));
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "secret set");
}

/// secret: `get` operation → `com.tsafe.vault.secret.accessed.v1`
#[test]
fn secret_get_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("dev", "get", Some("API_KEY"));
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "secret get");
}

/// secret: `delete` operation → `com.tsafe.vault.secret.deleted.v1`
#[test]
fn secret_delete_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "delete", Some("OLD_TOKEN"));
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "secret delete");
}

/// secret: `rotate-due` operation → `com.tsafe.secret.rotation_due.v1`
///
/// This is a key-less secret event (no specific key involved in the rotation
/// check sweep); `key_ref` is expected to be `null`.
#[test]
fn secret_rotation_due_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "rotate-due", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "secret rotation_due");
}

/// session: `unlock` operation → `com.tsafe.session.unlocked.v1`
#[test]
fn session_unlocked_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/session/session-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "unlock", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "session unlocked");
}

/// vault: `init` operation → `com.tsafe.vault.created.v1`
#[test]
fn vault_created_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "init", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "vault created (init)");
}

/// vault: `rotate` operation → `com.tsafe.vault.rotated.v1`
#[test]
fn vault_rotated_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "rotate", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "vault rotated");
}

/// vault: `export` operation → `com.tsafe.vault.exported.v1`
///
/// Note: `classify_operation("export")` maps this to the *secret* domain
/// (`SecretLifecycleState::Exported`) with event type `com.tsafe.vault.exported.v1`,
/// but the vault schema's `type` enum includes `com.tsafe.vault.exported.v1`.
/// The secret schema does NOT include this type. This test validates against
/// the vault schema which correctly covers this type.
#[test]
fn vault_exported_event_validates_against_vault_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "export", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "vault exported");
}

/// vault: `exec` operation without authority context → `com.tsafe.vault.exec.v1`
///
/// The `authority` field is absent when no exec context is attached.
#[test]
fn vault_exec_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("work", "exec", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "vault exec (no authority)");
}

/// sync: `kv-pull` operation → `com.tsafe.sync.pull.completed.v1`
#[test]
fn sync_pull_completed_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/sync/sync-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "kv-pull", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "sync pull completed (kv-pull)");
}

/// sync: `vault-pull` variant — same schema type as `kv-pull`
#[test]
fn sync_vault_pull_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/sync/sync-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("work", "vault-pull", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "sync pull completed (vault-pull)");
}

/// share: `share-once` operation → `com.tsafe.share.published.v1`
#[test]
fn share_published_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/share/share-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "share-once", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "share published");
}

/// share: `receive-once` operation → `com.tsafe.share.consumed.v1`
#[test]
fn share_consumed_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/share/share-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "receive-once", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "share consumed");
}

// ── C7.1 — browser gap test ───────────────────────────────────────────────────

/// Document the gap: no audit operation maps to `com.tsafe.browser.phishing_blocked.v1`.
///
/// The browser event schema exists for the browser extension to emit events,
/// but the tsafe-core audit log has no operation that produces a browser event
/// type via `CloudEvent::from_audit()`. The browser extension emits events
/// directly; tsafe-core has no server-side audit path for phishing blocks.
///
/// This test validates a hand-constructed minimal payload to confirm the schema
/// file is well-formed and parseable, but acknowledges that a real browser event
/// cannot be constructed via the standard `from_audit()` path.
#[test]
fn browser_event_gap_hand_constructed_payload_validates() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/browser/browser-event.v1.schema.json"
    ));
    // Construct a minimal browser event directly — the only way to exercise
    // this schema from Rust since no audit operation maps to it.
    let data = serde_json::json!({
        "audit_id": "00000000-0000-4000-8000-000000000001",
        "operation": "browser-phishing-blocked",
        "key_ref": null,
        "status": "success",
        "message": null
    });
    let event = CloudEvent::new(
        "tsafe/browser",
        "com.tsafe.browser.phishing_blocked.v1",
        data,
    );
    assert_validates(
        &validator,
        &event,
        "browser phishing_blocked (hand-constructed)",
    );
}

// ── authority.exec roundtrip test ────────────────────────────────────────────

/// Confirm that the `authority.exec` shape emitted by `project_exec_context()`
/// now passes schema validation (task C7-fix).
///
/// Previously the schemas defined a speculative `{command, pid, authority_profile}`
/// shape. The schemas were updated to match the runtime's actual output:
/// `{contract_name, target, trust_level, network, allowed_secret_refs, …}`.
///
/// This test constructs an exec-context event and validates it against the vault
/// schema — the primary schema for exec events — confirming the gap is closed.
#[test]
fn authority_exec_shape_matches_runtime() {
    use tsafe_core::{
        audit::{AuditContext, AuditExecContext},
        contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust},
        rbac::RbacProfile,
    };

    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));

    let contract = AuthorityContract {
        name: "deploy".into(),
        profile: Some("main".into()),
        namespace: Some("infra".into()),
        access_profile: RbacProfile::ReadOnly,
        allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
        required_secrets: vec!["DB_PASSWORD".into()],
        allowed_targets: vec!["deploy.sh".into()],
        trust: AuthorityTrust::Hardened,
        network: AuthorityNetworkPolicy::Restricted,
    };

    let entry = AuditEntry::success("main", "exec", None).with_context(AuditContext::from_exec(
        AuditExecContext::from_contract(&contract)
            .with_target("/scripts/deploy.sh")
            .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("/scripts/deploy.sh"))),
    ));

    let event = CloudEvent::from_audit(&entry);

    // The authority.exec block must be present in the emitted event.
    let exec = &event.data["authority"]["exec"];
    assert!(!exec.is_null(), "authority.exec must be present");
    assert_eq!(exec["contract_name"], "deploy");
    assert_eq!(exec["trust_level"], "hardened");
    assert_eq!(exec["network"], "restricted");

    // Schema validation must succeed — this is the core assertion of C7-fix.
    assert_validates(&validator, &event, "vault exec with authority (C7-fix)");
}

// ── C7.2 — no-plaintext-in-spans assertion ────────────────────────────────────

/// ADR-022 §5 / ADR-024: no plaintext secret value may appear in any emitted
/// event payload at any nesting depth.
///
/// This test covers the serialised-JSON surface (the universal representation
/// shared by all adapters: outbox file, webhook, NATS). It complements the
/// existing `no_plaintext_secret_value_in_event_payload` unit test in
/// `events.rs` by exercising the schema-validated path.
///
/// # OTel / tracing instrumentation gap
///
/// `events.rs` does not instrument event payloads via `tracing` spans or
/// attributes. The only `tracing::warn!` call sites in the module are
/// adapter-error paths (webhook POST failed, NATS publish failed) — they log
/// the adapter URL and error string, never the event payload or any secret
/// value. Therefore there are no span attributes to assert against for the
/// no-plaintext invariant; the serialised-JSON check below is the mechanical
/// enforcement point.
///
/// If future work adds `tracing::info_span!` around event construction or
/// adapter dispatch, a `tracing_subscriber::fmt::TestWriter`-based test must
/// be added to assert span fields do not contain secret values.
#[test]
fn no_plaintext_secret_in_serialised_events_all_families() {
    // Use values that would be obvious leaks if they appeared anywhere.
    let sensitive_key = "PROD_STRIPE_SECRET_KEY";
    let sensitive_value = "sk_live_super_secret_plaintext_value_9999";

    // Each event family is exercised with an operation that includes a key.
    let families: &[(&str, &str, Option<&str>)] = &[
        ("secret", "set", Some(sensitive_key)),
        ("secret", "get", Some(sensitive_key)),
        ("secret", "delete", Some(sensitive_key)),
        ("session", "unlock", None),
        ("vault", "init", None),
        ("sync", "kv-pull", None),
        ("share", "share-once", None),
    ];

    for (family, operation, key) in families {
        let entry = AuditEntry::success("prod", operation, *key);
        let event = CloudEvent::from_audit(&entry);
        let serialised = serde_json::to_string(&event)
            .unwrap_or_else(|e| panic!("{family}/{operation}: serialise failed: {e}"));

        assert!(
            !serialised.contains(sensitive_key),
            "{family}/{operation}: plaintext key name '{sensitive_key}' leaked into event JSON"
        );
        assert!(
            !serialised.contains(sensitive_value),
            "{family}/{operation}: plaintext secret value leaked into event JSON"
        );

        // Confirm key_ref is present and opaque (when a key was supplied).
        if let Some(k) = key {
            let v: serde_json::Value = serde_json::from_str(&serialised).unwrap();
            let kr = v["data"]["key_ref"].as_str().unwrap_or_else(|| {
                panic!("{family}/{operation}: key_ref must be a string when key is present")
            });
            assert_eq!(
                kr.len(),
                64,
                "{family}/{operation}: key_ref must be 64-char SHA-256 hex"
            );
            assert_eq!(
                kr,
                key_ref("prod", k),
                "{family}/{operation}: key_ref must match SHA-256(profile:key)"
            );
        }
    }
}

/// Confirm that the `key_ref` opaqueness holds: the plaintext key name must not
/// be reconstructible from any field in the serialised event by a reader who
/// only sees the event (without access to the profile and key name).
#[test]
fn key_ref_is_opaque_in_schema_validated_event() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "set", Some("GITHUB_TOKEN"));
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "key_ref opaqueness check");

    let serialised = serde_json::to_string(&event).unwrap();
    assert!(
        !serialised.contains("GITHUB_TOKEN"),
        "plaintext key name must not appear anywhere in a schema-validated event"
    );

    let value: serde_json::Value = serde_json::from_str(&serialised).unwrap();
    let kr = value["data"]["key_ref"].as_str().unwrap();
    assert_eq!(kr, key_ref("main", "GITHUB_TOKEN"));
    assert_eq!(kr.len(), 64);
}