Skip to main content

wist_contracts/
enrollment.rs

1//! Agent enrollment contract types.
2
3use serde::{Deserialize, Serialize};
4
5use crate::API_VERSION_V1;
6
7pub const SUBMIT_ENROLLMENT_REQUEST_KIND: &str = "submit_enrollment_request";
8pub const RENEW_AGENT_CREDENTIAL_KIND: &str = "renew_agent_credential";
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct EnrollmentRequest {
13    pub api_version: String,
14    pub kind: String,
15    pub token: String,
16    pub credential_request: String,
17    /// agent 本地生成的 **CSR**(PEM)。mTLS 是唯一凭据路径,注册**必须**带 CSR ——
18    /// 网关据此签一张客户端证书;没有它就没有凭据可用(不再回落 bearer)。
19    ///
20    /// 私钥**永不上送**,只交公钥;且**主体由网关填** —— CSR 里声明的 subject/SAN 一律忽略,
21    /// 网关按稳定哈希 `agent_id` 生成 URI SAN(见 `docs/design/agent-identity-mtls.md` §4.2)。
22    pub certificate_signing_request: String,
23    pub host_profile: HostProfile,
24    pub capability_summary: String,
25    pub requested_at: String,
26}
27
28impl EnrollmentRequest {
29    pub fn new(
30        token: String,
31        credential_request: String,
32        certificate_signing_request: String,
33        host_profile: HostProfile,
34        capability_summary: String,
35        requested_at: String,
36    ) -> Self {
37        Self {
38            api_version: API_VERSION_V1.to_string(),
39            kind: SUBMIT_ENROLLMENT_REQUEST_KIND.to_string(),
40            token,
41            credential_request,
42            certificate_signing_request,
43            host_profile,
44            capability_summary,
45            requested_at,
46        }
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
51#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
52#[serde(deny_unknown_fields)]
53pub struct HostProfile {
54    pub node_id: String,
55    pub hostname: String,
56    pub os: String,
57    pub arch: String,
58    pub machine_id: String,
59    pub cloud_instance_id: Option<String>,
60    pub k8s_node_uid: Option<String>,
61    pub ip_addresses: Vec<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct EnrollmentEnvelope {
67    pub result: EnrollmentOutcome,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct EnrollmentOutcome {
73    pub status: EnrollmentStatus,
74    pub reason_code: Option<String>,
75    pub agent_id: Option<String>,
76    pub instance_id: Option<String>,
77    pub issued_identity: Option<AgentIdentity>,
78    pub credential_bundle: Option<CredentialBundle>,
79    pub initial_config: Option<InitialConfig>,
80    pub policy_binding: Option<PolicyBinding>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84pub enum EnrollmentStatus {
85    #[serde(rename = "accepted")]
86    Accepted,
87    #[serde(rename = "rejected")]
88    Rejected,
89    #[serde(rename = "pending_review")]
90    PendingReview,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
94#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
95#[serde(deny_unknown_fields)]
96pub struct AgentIdentity {
97    pub agent_id: String,
98    pub instance_id: String,
99    pub tenant_id: String,
100    pub environment_id: String,
101    pub node_id: String,
102    pub issued_at: String,
103    pub expires_at: Option<String>,
104    pub status: AgentIdentityStatus,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
108#[jumo(kind = "state", domain = "Control", module = "Control.Agent.Identity")]
109pub enum AgentIdentityStatus {
110    #[serde(rename = "active")]
111    Active,
112    #[serde(rename = "revoked")]
113    Revoked,
114    #[serde(rename = "expired")]
115    Expired,
116    #[serde(rename = "renewal_required")]
117    RenewalRequired,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
121#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
122#[serde(deny_unknown_fields)]
123pub struct CredentialBundle {
124    pub credential_id: String,
125    pub agent_id: String,
126    pub instance_id: String,
127    /// 客户端证书(PEM)。mTLS 是 agent 与网关之间的**唯一**凭据路径(bearer 已删),
128    /// 所以这里是必填:注册/续期都必须换回一张证书。
129    pub certificate: String,
130    pub private_key_ref: Option<String>,
131    pub ca_bundle: Option<String>,
132    pub issued_at: String,
133    pub not_before: Option<String>,
134    pub not_after: Option<String>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct CredentialRenewal {
140    pub api_version: String,
141    pub kind: String,
142    pub agent_id: String,
143    pub instance_id: String,
144    pub credential_request: String,
145    /// 续期时提交的 **CSR**(PEM)。与注册同口径:私钥不上送、主体由网关填
146    /// (见 `docs/design/agent-identity-mtls.md` §4.2)。mTLS 是唯一凭据路径,续期**必须**带它。
147    pub certificate_signing_request: String,
148    pub requested_at: String,
149}
150
151impl CredentialRenewal {
152    pub fn new(
153        agent_id: String,
154        instance_id: String,
155        credential_request: String,
156        certificate_signing_request: String,
157        requested_at: String,
158    ) -> Self {
159        Self {
160            api_version: API_VERSION_V1.to_string(),
161            kind: RENEW_AGENT_CREDENTIAL_KIND.to_string(),
162            agent_id,
163            instance_id,
164            credential_request,
165            certificate_signing_request,
166            requested_at,
167        }
168    }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct CredentialRenewed {
174    pub credential_bundle: CredentialBundle,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct InitialConfig {
180    pub schema_version: String,
181    pub mode: String,
182    pub gateway_endpoint: String,
183    pub policy_version: String,
184    pub telemetry_output: Option<String>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct PolicyBinding {
190    pub agent_id: String,
191    pub policy_id: String,
192    pub policy_version: String,
193    pub bound_at: String,
194}
195
196#[cfg(test)]
197mod tests {
198    use super::{
199        CredentialBundle, CredentialRenewal, EnrollmentEnvelope, EnrollmentOutcome,
200        EnrollmentRequest, EnrollmentStatus, HostProfile, RENEW_AGENT_CREDENTIAL_KIND,
201    };
202
203    /// 契约收口(2026-09-30):agent 的凭据包**只剩客户端证书** —— 线上不再有 `bearer_token` /
204    /// `auth_scheme`,而 `certificate` 是必填。带旧字段的老报文一律拒(`deny_unknown_fields`)。
205    #[test]
206    fn credential_bundle_carries_only_the_client_certificate() {
207        let bundle = CredentialBundle {
208            credential_id: "cred-1".to_string(),
209            agent_id: "agent-1".to_string(),
210            instance_id: "inst-1".to_string(),
211            certificate: "CERT".to_string(),
212            private_key_ref: None,
213            ca_bundle: None,
214            issued_at: "2026-09-30T00:00:00Z".to_string(),
215            not_before: None,
216            not_after: None,
217        };
218        let json = serde_json::to_string(&bundle).expect("encode");
219        assert!(!json.contains("bearer_token"), "{json}");
220        assert!(!json.contains("auth_scheme"), "{json}");
221
222        // 带旧字段的老报文解不了(字段已从契约里删掉)。
223        let legacy = r#"{"credential_id":"c","agent_id":"a","instance_id":"i",\
224            "auth_scheme":"bearer","bearer_token":"wic_x","certificate":"CERT",\
225            "private_key_ref":null,"ca_bundle":null,"issued_at":"t",\
226            "not_before":null,"not_after":null}"#;
227        assert!(serde_json::from_str::<CredentialBundle>(legacy).is_err());
228
229        // certificate 必填:缺了就解不了(mTLS 是唯一凭据路径,没有它就没有凭据)。
230        let no_certificate = r#"{"credential_id":"c","agent_id":"a","instance_id":"i",\
231            "private_key_ref":null,"ca_bundle":null,"issued_at":"t",\
232            "not_before":null,"not_after":null}"#;
233        assert!(serde_json::from_str::<CredentialBundle>(no_certificate).is_err());
234    }
235
236    #[test]
237    fn enrollment_result_status_uses_wire_names() {
238        let decoded: EnrollmentEnvelope =
239            serde_json::from_str(r#"{"result":{"status":"accepted","reason_code":null,"agent_id":"agent-1","instance_id":"host-a","issued_identity":null,"credential_bundle":null,"initial_config":null,"policy_binding":null}}"#)
240                .expect("decode");
241
242        assert_eq!(decoded.result.status, EnrollmentStatus::Accepted);
243
244        let encoded = serde_json::to_string(&EnrollmentOutcome {
245            status: EnrollmentStatus::PendingReview,
246            reason_code: Some("manual_review".to_string()),
247            agent_id: None,
248            instance_id: None,
249            issued_identity: None,
250            credential_bundle: None,
251            initial_config: None,
252            policy_binding: None,
253        })
254        .expect("encode");
255
256        assert!(encoded.contains("\"pending_review\""));
257    }
258
259    #[test]
260    fn renew_agent_credential_uses_stable_wire_kind() {
261        let request = CredentialRenewal::new(
262            "agent-a".to_string(),
263            "instance-a".to_string(),
264            "csr".to_string(),
265            "-----BEGIN CERTIFICATE REQUEST-----\nA\n".to_string(),
266            "2026-07-29T00:00:00Z".to_string(),
267        );
268        let encoded = serde_json::to_string(&request).expect("encode");
269
270        assert!(encoded.contains(&format!("\"kind\":\"{RENEW_AGENT_CREDENTIAL_KIND}\"")));
271        assert!(encoded.contains("certificate_signing_request"));
272
273        let decoded: CredentialRenewal = serde_json::from_str(&encoded).expect("decode");
274        assert_eq!(decoded.api_version, "v1");
275        assert_eq!(decoded.kind, RENEW_AGENT_CREDENTIAL_KIND);
276
277        // CSR 是必填:缺该字段的报文解不了(不再有「只要 bearer」的双轨报文)。
278        let mut without_csr = serde_json::to_value(&request).expect("encode");
279        without_csr
280            .as_object_mut()
281            .expect("object")
282            .remove("certificate_signing_request");
283        assert!(serde_json::from_value::<CredentialRenewal>(without_csr).is_err());
284    }
285
286    #[test]
287    fn enrollment_status_uses_wire_names_and_rejects_unknown_variants() {
288        for (status, name) in [
289            (EnrollmentStatus::Accepted, "accepted"),
290            (EnrollmentStatus::Rejected, "rejected"),
291            (EnrollmentStatus::PendingReview, "pending_review"),
292        ] {
293            assert_eq!(
294                serde_json::to_string(&status).unwrap(),
295                format!("\"{name}\"")
296            );
297        }
298        assert!(serde_json::from_str::<EnrollmentStatus>("\"unknown\"").is_err());
299    }
300
301    #[test]
302    fn enrollment_request_carries_a_required_csr() {
303        let profile = sample_host_profile();
304        let request = EnrollmentRequest::new(
305            "token-a".to_string(),
306            "csr".to_string(),
307            "-----BEGIN CERTIFICATE REQUEST-----\nA\n-----END CERTIFICATE REQUEST-----\n"
308                .to_string(),
309            profile,
310            "wist-agentd:test".to_string(),
311            "2026-09-28T00:00:00Z".to_string(),
312        );
313        let json = serde_json::to_string(&request).expect("encode");
314        assert!(json.contains("certificate_signing_request"));
315        let back: EnrollmentRequest = serde_json::from_str(&json).expect("decode");
316        assert_eq!(back, request);
317
318        // CSR 必填:缺该字段的报文解不了(注册不再有「不带证书」的退路)。
319        let mut without_csr = serde_json::to_value(&request).expect("encode");
320        without_csr
321            .as_object_mut()
322            .expect("object")
323            .remove("certificate_signing_request");
324        assert!(serde_json::from_value::<EnrollmentRequest>(without_csr).is_err());
325    }
326
327    fn sample_host_profile() -> HostProfile {
328        HostProfile {
329            node_id: "node-1".to_string(),
330            hostname: "host-1".to_string(),
331            os: "linux".to_string(),
332            arch: "x86_64".to_string(),
333            machine_id: "mid-1".to_string(),
334            cloud_instance_id: None,
335            k8s_node_uid: None,
336            ip_addresses: vec!["10.0.0.1".to_string()],
337        }
338    }
339
340    #[test]
341    fn host_profile_round_trips_and_rejects_unknown_fields() {
342        let profile = HostProfile {
343            node_id: "node-1".to_string(),
344            hostname: "host-1".to_string(),
345            os: "macos".to_string(),
346            arch: "arm64".to_string(),
347            machine_id: "mid-1".to_string(),
348            cloud_instance_id: None,
349            k8s_node_uid: None,
350            ip_addresses: vec!["10.0.0.1".to_string()],
351        };
352        let json = serde_json::to_string(&profile).expect("encode");
353        let back: HostProfile = serde_json::from_str(&json).expect("decode");
354        assert_eq!(back, profile);
355
356        let mutated = json.replacen('{', "{\"extra\":1,", 1);
357        assert!(serde_json::from_str::<HostProfile>(&mutated).is_err());
358    }
359
360    #[test]
361    fn an_outcome_without_optional_payloads_decodes() {
362        // 拒绝的注册只带 status/reason_code:其余 Option 字段缺省即可。
363        let json = r#"{"result":{"status":"rejected","reason_code":"bad_token"}}"#;
364        let envelope: EnrollmentEnvelope = serde_json::from_str(json).expect("decode");
365        assert_eq!(envelope.result.status, EnrollmentStatus::Rejected);
366        assert!(envelope.result.issued_identity.is_none());
367        assert!(envelope.result.credential_bundle.is_none());
368    }
369}