Skip to main content

wist_api/enrollment/
v1.rs

1//! `agent/enroll` 与 `agent/credentials:renew` —— **v1** 基线。
2//!
3//! 这是这两条 seam 的**冻结基线**:只做**加性**兼容不动它;一旦需要非加性变更,
4//! 就新开 `v2`(另一组 `{route, req, resp}`),本模块**只增不删**(长期要给旧 agent 用)。
5//! 约定见 `wist-design/doc/design/foundation/api-seam-inventory.md` §7。
6
7use serde::{Deserialize, Serialize};
8
9use super::{AgentIdentity, CredentialBundle, HostProfile, InitialConfig, PolicyBinding};
10use wist_contracts::API_VERSION_V1;
11
12pub const SUBMIT_ENROLLMENT_REQUEST_KIND: &str = "submit_enrollment_request";
13pub const RENEW_AGENT_CREDENTIAL_KIND: &str = "renew_agent_credential";
14
15/// 本版本的线上版本号(与路由 `/api/v1/…` 一致)。
16pub const API_VERSION: &str = API_VERSION_V1;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct EnrollmentRequest {
21    pub api_version: String,
22    pub kind: String,
23    pub token: String,
24    pub credential_request: String,
25    /// agent 本地生成的 **CSR**(PEM)。mTLS 是唯一凭据路径,注册**必须**带 CSR ——
26    /// 网关据此签一张客户端证书;没有它就没有凭据可用(不再回落 bearer)。
27    ///
28    /// 私钥**永不上送**,只交公钥;且**主体由网关填** —— CSR 里声明的 subject/SAN 一律忽略,
29    /// 网关按稳定哈希 `agent_id` 生成 URI SAN(见 `docs/design/agent-identity-mtls.md` §4.2)。
30    pub certificate_signing_request: String,
31    pub host_profile: HostProfile,
32    pub capability_summary: String,
33    pub requested_at: String,
34}
35
36impl EnrollmentRequest {
37    pub fn new(
38        token: String,
39        credential_request: String,
40        certificate_signing_request: String,
41        host_profile: HostProfile,
42        capability_summary: String,
43        requested_at: String,
44    ) -> Self {
45        Self {
46            api_version: API_VERSION.to_string(),
47            kind: SUBMIT_ENROLLMENT_REQUEST_KIND.to_string(),
48            token,
49            credential_request,
50            certificate_signing_request,
51            host_profile,
52            capability_summary,
53            requested_at,
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct EnrollmentEnvelope {
61    pub result: EnrollmentOutcome,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct EnrollmentOutcome {
67    pub status: EnrollmentStatus,
68    pub reason_code: Option<String>,
69    pub agent_id: Option<String>,
70    pub instance_id: Option<String>,
71    pub issued_identity: Option<AgentIdentity>,
72    pub credential_bundle: Option<CredentialBundle>,
73    pub initial_config: Option<InitialConfig>,
74    pub policy_binding: Option<PolicyBinding>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub enum EnrollmentStatus {
79    #[serde(rename = "accepted")]
80    Accepted,
81    #[serde(rename = "rejected")]
82    Rejected,
83    #[serde(rename = "pending_review")]
84    PendingReview,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct CredentialRenewal {
90    pub api_version: String,
91    pub kind: String,
92    pub agent_id: String,
93    pub instance_id: String,
94    pub credential_request: String,
95    /// 续期时提交的 **CSR**(PEM)。与注册同口径:私钥不上送、主体由网关填
96    /// (见 `docs/design/agent-identity-mtls.md` §4.2)。mTLS 是唯一凭据路径,续期**必须**带它。
97    pub certificate_signing_request: String,
98    pub requested_at: String,
99}
100
101impl CredentialRenewal {
102    pub fn new(
103        agent_id: String,
104        instance_id: String,
105        credential_request: String,
106        certificate_signing_request: String,
107        requested_at: String,
108    ) -> Self {
109        Self {
110            api_version: API_VERSION.to_string(),
111            kind: RENEW_AGENT_CREDENTIAL_KIND.to_string(),
112            agent_id,
113            instance_id,
114            credential_request,
115            certificate_signing_request,
116            requested_at,
117        }
118    }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct CredentialRenewed {
124    pub credential_bundle: CredentialBundle,
125}
126
127#[cfg(test)]
128mod tests {
129    use super::{
130        CredentialRenewal, EnrollmentEnvelope, EnrollmentOutcome, EnrollmentRequest,
131        EnrollmentStatus, HostProfile, RENEW_AGENT_CREDENTIAL_KIND,
132    };
133
134    #[test]
135    fn enrollment_result_status_uses_wire_names() {
136        let decoded: EnrollmentEnvelope =
137            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}}"#)
138                .expect("decode");
139
140        assert_eq!(decoded.result.status, EnrollmentStatus::Accepted);
141
142        let encoded = serde_json::to_string(&EnrollmentOutcome {
143            status: EnrollmentStatus::PendingReview,
144            reason_code: Some("manual_review".to_string()),
145            agent_id: None,
146            instance_id: None,
147            issued_identity: None,
148            credential_bundle: None,
149            initial_config: None,
150            policy_binding: None,
151        })
152        .expect("encode");
153
154        assert!(encoded.contains("\"pending_review\""));
155    }
156
157    #[test]
158    fn renew_agent_credential_uses_stable_wire_kind() {
159        let request = CredentialRenewal::new(
160            "agent-a".to_string(),
161            "instance-a".to_string(),
162            "csr".to_string(),
163            "-----BEGIN CERTIFICATE REQUEST-----\nA\n".to_string(),
164            "2026-07-29T00:00:00Z".to_string(),
165        );
166        let encoded = serde_json::to_string(&request).expect("encode");
167
168        assert!(encoded.contains(&format!("\"kind\":\"{RENEW_AGENT_CREDENTIAL_KIND}\"")));
169        assert!(encoded.contains("certificate_signing_request"));
170
171        let decoded: CredentialRenewal = serde_json::from_str(&encoded).expect("decode");
172        assert_eq!(decoded.api_version, "v1");
173        assert_eq!(decoded.kind, RENEW_AGENT_CREDENTIAL_KIND);
174
175        // CSR 是必填:缺该字段的报文解不了(不再有「只要 bearer」的双轨报文)。
176        let mut without_csr = serde_json::to_value(&request).expect("encode");
177        without_csr
178            .as_object_mut()
179            .expect("object")
180            .remove("certificate_signing_request");
181        assert!(serde_json::from_value::<CredentialRenewal>(without_csr).is_err());
182    }
183
184    #[test]
185    fn enrollment_status_uses_wire_names_and_rejects_unknown_variants() {
186        for (status, name) in [
187            (EnrollmentStatus::Accepted, "accepted"),
188            (EnrollmentStatus::Rejected, "rejected"),
189            (EnrollmentStatus::PendingReview, "pending_review"),
190        ] {
191            assert_eq!(
192                serde_json::to_string(&status).unwrap(),
193                format!("\"{name}\"")
194            );
195        }
196        assert!(serde_json::from_str::<EnrollmentStatus>("\"unknown\"").is_err());
197    }
198
199    #[test]
200    fn enrollment_request_carries_a_required_csr() {
201        let profile = sample_host_profile();
202        let request = EnrollmentRequest::new(
203            "token-a".to_string(),
204            "csr".to_string(),
205            "-----BEGIN CERTIFICATE REQUEST-----\nA\n-----END CERTIFICATE REQUEST-----\n"
206                .to_string(),
207            profile,
208            "wist-agentd:test".to_string(),
209            "2026-09-28T00:00:00Z".to_string(),
210        );
211        let json = serde_json::to_string(&request).expect("encode");
212        assert!(json.contains("certificate_signing_request"));
213        let back: EnrollmentRequest = serde_json::from_str(&json).expect("decode");
214        assert_eq!(back, request);
215
216        // CSR 必填:缺该字段的报文解不了(注册不再有「不带证书」的退路)。
217        let mut without_csr = serde_json::to_value(&request).expect("encode");
218        without_csr
219            .as_object_mut()
220            .expect("object")
221            .remove("certificate_signing_request");
222        assert!(serde_json::from_value::<EnrollmentRequest>(without_csr).is_err());
223    }
224
225    fn sample_host_profile() -> HostProfile {
226        HostProfile {
227            node_id: "node-1".to_string(),
228            hostname: "host-1".to_string(),
229            os: "linux".to_string(),
230            arch: "x86_64".to_string(),
231            machine_id: "mid-1".to_string(),
232            cloud_instance_id: None,
233            k8s_node_uid: None,
234            ip_addresses: vec!["10.0.0.1".to_string()],
235        }
236    }
237
238    #[test]
239    fn an_outcome_without_optional_payloads_decodes() {
240        // 拒绝的注册只带 status/reason_code:其余 Option 字段缺省即可。
241        let json = r#"{"result":{"status":"rejected","reason_code":"bad_token"}}"#;
242        let envelope: EnrollmentEnvelope = serde_json::from_str(json).expect("decode");
243        assert_eq!(envelope.result.status, EnrollmentStatus::Rejected);
244        assert!(envelope.result.issued_identity.is_none());
245        assert!(envelope.result.credential_bundle.is_none());
246    }
247
248    /// `EnrollmentRequest::new` 必须带上**本版本号**与稳定 kind(seam 的两把“钥匙”)。
249    #[test]
250    fn new_fills_the_wire_version_and_kind() {
251        let request = EnrollmentRequest::new(
252            "t".to_string(),
253            "cr".to_string(),
254            "csr".to_string(),
255            sample_host_profile(),
256            "caps".to_string(),
257            "2026-09-28T00:00:00Z".to_string(),
258        );
259        assert_eq!(request.api_version, super::API_VERSION);
260        assert_eq!(request.kind, super::SUBMIT_ENROLLMENT_REQUEST_KIND);
261    }
262
263    /// 钉住 `EnrollmentRequest` 的**线上字段集**:多一个/少一个都会在此失败。
264    #[test]
265    fn enrollment_request_wire_keys_are_stable() {
266        let request = EnrollmentRequest::new(
267            "t".to_string(),
268            "cr".to_string(),
269            "csr".to_string(),
270            sample_host_profile(),
271            "caps".to_string(),
272            "2026-09-28T00:00:00Z".to_string(),
273        );
274        let value = serde_json::to_value(&request).expect("encode");
275        let mut keys: Vec<&str> = value
276            .as_object()
277            .expect("object")
278            .keys()
279            .map(String::as_str)
280            .collect();
281        keys.sort_unstable();
282        assert_eq!(
283            keys,
284            [
285                "api_version",
286                "capability_summary",
287                "certificate_signing_request",
288                "credential_request",
289                "host_profile",
290                "kind",
291                "requested_at",
292                "token",
293            ]
294        );
295    }
296
297    /// 钉住 `EnrollmentOutcome` 的**线上字段集**(回执报文)。
298    #[test]
299    fn enrollment_outcome_wire_keys_are_stable() {
300        let outcome = EnrollmentOutcome {
301            status: EnrollmentStatus::Accepted,
302            reason_code: None,
303            agent_id: None,
304            instance_id: None,
305            issued_identity: None,
306            credential_bundle: None,
307            initial_config: None,
308            policy_binding: None,
309        };
310        let value = serde_json::to_value(&outcome).expect("encode");
311        let mut keys: Vec<&str> = value
312            .as_object()
313            .expect("object")
314            .keys()
315            .map(String::as_str)
316            .collect();
317        keys.sort_unstable();
318        assert_eq!(
319            keys,
320            [
321                "agent_id",
322                "credential_bundle",
323                "initial_config",
324                "instance_id",
325                "issued_identity",
326                "policy_binding",
327                "reason_code",
328                "status",
329            ]
330        );
331    }
332}