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
//! v2.49.0 — the tenant reaches custody through the REAL executor path.
//!
//! The v2.48.0/v2.49.0 custody runtime tests drive `dispatch_node` with a hand-built
//! `DispatchCtx` whose `tenant_id` is set MANUALLY. The DEPLOYED path is
//! `execute_server_flow`, which — before v2.49.0 — never populated
//! `ctx.tenant_id`, so every explicit-tenant custody call (`rotate`
//! enumeration/reveal, `retrieve` over a `backend: secrets` store, `tool {
//! secret: }` / `secret_partition:` injection) reached the port with an EMPTY
//! tenant and the production `PgSecretCustody` refused it fail-closed
//! ("custody requires an explicit tenant") — on the daemon AND the endpoint.
//!
//! This gate drives `execute_server_flow` end-to-end with a spying custody and
//! asserts the port sees the tenant the caller passed — the exact seam a unit
//! test over `dispatch_node` cannot cover (`reference_enterprise_flow_execution_path`).
use axon::secret_custody::{
CustodyError, RevealedSecret, SecretCustody, SecretMetadata,
};
use std::sync::{Arc, Mutex};
/// A custody that records the tenant of every call. It never refuses on an
/// empty tenant (that is the ENT `PgSecretCustody` policy) — the point here is
/// to OBSERVE which tenant the executor threaded, so a regression (empty
/// tenant) is caught in OSS CI without a live Postgres.
#[derive(Default)]
struct SpyCustody {
seen_tenants: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl SecretCustody for SpyCustody {
async fn list_metadata(
&self,
tenant: &str,
_class_prefix: &str,
) -> Result<Vec<SecretMetadata>, CustodyError> {
self.seen_tenants.lock().unwrap().push(tenant.to_string());
Ok(vec![])
}
async fn reveal_for_rotation(
&self,
tenant: &str,
key: &str,
) -> Result<RevealedSecret, CustodyError> {
self.seen_tenants.lock().unwrap().push(tenant.to_string());
Err(CustodyError::NotFound { key: key.to_string() })
}
async fn commit_rotation(
&self,
tenant: &str,
key: &str,
_v: &str,
_e: Option<i64>,
_ev: i64,
) -> Result<SecretMetadata, CustodyError> {
self.seen_tenants.lock().unwrap().push(tenant.to_string());
Err(CustodyError::NotFound { key: key.to_string() })
}
async fn reveal_for_dispatch(
&self,
tenant: &str,
key: &str,
) -> Result<RevealedSecret, CustodyError> {
self.seen_tenants.lock().unwrap().push(tenant.to_string());
Err(CustodyError::NotFound { key: key.to_string() })
}
}
/// A `retrieve` over a `backend: secrets` store enumerates custody metadata —
/// the cleanest single-call custody trigger (the `rotate` sweep begins with the
/// same `list_metadata`). Driving it through `execute_server_flow` proves the
/// executor threads the tenant to the port.
const ENUM_SRC: &str = r#"
axonstore CrmTokens {
backend: secrets
class: crm
}
flow Enumerate() -> Unit {
retrieve CrmTokens { where: "version > 0" as: rows }
}
"#;
fn run_enumerate_with_tenant(tenant: &str) -> Vec<String> {
let (_p, ir) =
axon::flow_plan::compile_source_to_ir(ENUM_SRC, "enum.axon").expect("compile");
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let custody = Arc::new(SpyCustody {
seen_tenants: seen.clone(),
});
let empty = std::collections::HashMap::new();
let _ = axon::runner::execute_server_flow(
&ir,
"Enumerate",
"stub",
tenant,
"enum.axon",
None,
None,
&empty,
&empty,
None,
None,
None,
None, // budget
None, // v2.69.0 channel semaphores
None, // v2.69.0 — tool leases (test: none)
None, // outbox
None, // minter
Some(custody as Arc<dyn SecretCustody>),
None, // v2.63.0 dataspace_engine (tests: fail closed)
None, // v2.56.0 scrape_overrides
);
let out = seen.lock().unwrap().clone();
out
}
#[test]
fn executor_threads_the_verified_tenant_to_custody() {
let seen = run_enumerate_with_tenant("acme");
assert!(
!seen.is_empty(),
"custody must be consulted for the secrets-store retrieve"
);
assert!(
seen.iter().all(|t| t == "acme"),
"the executor must thread the caller's tenant to custody — before v2.49.0 \
this was empty and the production custody refused it: {seen:?}"
);
}
#[test]
fn executor_passes_empty_tenant_verbatim_when_unscoped() {
// A CLI/test with no scope passes "" → custody receives "" and (in prod)
// fails closed. The regression we guard is the OPPOSITE: a SCOPED caller
// whose tenant silently became "". Here we pin that "" stays "", not that
// the spy refuses (that is the ENT policy).
let seen = run_enumerate_with_tenant("");
assert!(
seen.iter().all(|t| t.is_empty()),
"an unscoped caller's empty tenant must pass through verbatim: {seen:?}"
);
}