Skip to main content

wist_api/
enrollment.rs

1//! `agent/enroll` 与 `agent/credentials:renew` 两条 edge seam 的报文定义。
2//!
3//! 这是**跨进程 seam 报文**的唯一一份定义:网关(接收端)与 agentd(发送端)都 `use` 这里,
4//! 不再各自复制。报文引用的**领域类型**(`HostProfile` / `AgentIdentity` / `CredentialBundle`
5//! / `InitialConfig` / `PolicyBinding`)是两侧共同底座,仍留在 `wist-contracts`;这里 re-export,
6//! 调用方可以从 `wist_api::enrollment` 一处取齐整条 seam。
7
8use serde::{Deserialize, Serialize};
9
10use wist_contracts::API_VERSION_V1;
11
12pub use wist_contracts::enrollment::{
13    AgentIdentity, AgentIdentityStatus, CredentialBundle, HostProfile, InitialConfig, PolicyBinding,
14};
15
16pub const SUBMIT_ENROLLMENT_REQUEST_KIND: &str = "submit_enrollment_request";
17pub const RENEW_AGENT_CREDENTIAL_KIND: &str = "renew_agent_credential";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct EnrollmentRequest {
22    pub api_version: String,
23    pub kind: String,
24    pub token: String,
25    pub credential_request: String,
26    /// agent 本地生成的 **CSR**(PEM)。mTLS 是唯一凭据路径,注册**必须**带 CSR ——
27    /// 网关据此签一张客户端证书;没有它就没有凭据可用(不再回落 bearer)。
28    ///
29    /// 私钥**永不上送**,只交公钥;且**主体由网关填** —— CSR 里声明的 subject/SAN 一律忽略,
30    /// 网关按稳定哈希 `agent_id` 生成 URI SAN(见 `docs/design/agent-identity-mtls.md` §4.2)。
31    pub certificate_signing_request: String,
32    pub host_profile: HostProfile,
33    pub capability_summary: String,
34    pub requested_at: String,
35}
36
37impl EnrollmentRequest {
38    pub fn new(
39        token: String,
40        credential_request: String,
41        certificate_signing_request: String,
42        host_profile: HostProfile,
43        capability_summary: String,
44        requested_at: String,
45    ) -> Self {
46        Self {
47            api_version: API_VERSION_V1.to_string(),
48            kind: SUBMIT_ENROLLMENT_REQUEST_KIND.to_string(),
49            token,
50            credential_request,
51            certificate_signing_request,
52            host_profile,
53            capability_summary,
54            requested_at,
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct EnrollmentEnvelope {
62    pub result: EnrollmentOutcome,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct EnrollmentOutcome {
68    pub status: EnrollmentStatus,
69    pub reason_code: Option<String>,
70    pub agent_id: Option<String>,
71    pub instance_id: Option<String>,
72    pub issued_identity: Option<AgentIdentity>,
73    pub credential_bundle: Option<CredentialBundle>,
74    pub initial_config: Option<InitialConfig>,
75    pub policy_binding: Option<PolicyBinding>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79pub enum EnrollmentStatus {
80    #[serde(rename = "accepted")]
81    Accepted,
82    #[serde(rename = "rejected")]
83    Rejected,
84    #[serde(rename = "pending_review")]
85    PendingReview,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct CredentialRenewal {
91    pub api_version: String,
92    pub kind: String,
93    pub agent_id: String,
94    pub instance_id: String,
95    pub credential_request: String,
96    /// 续期时提交的 **CSR**(PEM)。与注册同口径:私钥不上送、主体由网关填
97    /// (见 `docs/design/agent-identity-mtls.md` §4.2)。mTLS 是唯一凭据路径,续期**必须**带它。
98    pub certificate_signing_request: String,
99    pub requested_at: String,
100}
101
102impl CredentialRenewal {
103    pub fn new(
104        agent_id: String,
105        instance_id: String,
106        credential_request: String,
107        certificate_signing_request: String,
108        requested_at: String,
109    ) -> Self {
110        Self {
111            api_version: API_VERSION_V1.to_string(),
112            kind: RENEW_AGENT_CREDENTIAL_KIND.to_string(),
113            agent_id,
114            instance_id,
115            credential_request,
116            certificate_signing_request,
117            requested_at,
118        }
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct CredentialRenewed {
125    pub credential_bundle: CredentialBundle,
126}
127
128#[cfg(test)]
129mod tests {
130    use super::{
131        CredentialRenewal, EnrollmentEnvelope, EnrollmentOutcome, EnrollmentRequest,
132        EnrollmentStatus, HostProfile, RENEW_AGENT_CREDENTIAL_KIND,
133    };
134
135    #[test]
136    fn enrollment_result_status_uses_wire_names() {
137        let decoded: EnrollmentEnvelope =
138            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}}"#)
139                .expect("decode");
140
141        assert_eq!(decoded.result.status, EnrollmentStatus::Accepted);
142
143        let encoded = serde_json::to_string(&EnrollmentOutcome {
144            status: EnrollmentStatus::PendingReview,
145            reason_code: Some("manual_review".to_string()),
146            agent_id: None,
147            instance_id: None,
148            issued_identity: None,
149            credential_bundle: None,
150            initial_config: None,
151            policy_binding: None,
152        })
153        .expect("encode");
154
155        assert!(encoded.contains("\"pending_review\""));
156    }
157
158    #[test]
159    fn renew_agent_credential_uses_stable_wire_kind() {
160        let request = CredentialRenewal::new(
161            "agent-a".to_string(),
162            "instance-a".to_string(),
163            "csr".to_string(),
164            "-----BEGIN CERTIFICATE REQUEST-----\nA\n".to_string(),
165            "2026-07-29T00:00:00Z".to_string(),
166        );
167        let encoded = serde_json::to_string(&request).expect("encode");
168
169        assert!(encoded.contains(&format!("\"kind\":\"{RENEW_AGENT_CREDENTIAL_KIND}\"")));
170        assert!(encoded.contains("certificate_signing_request"));
171
172        let decoded: CredentialRenewal = serde_json::from_str(&encoded).expect("decode");
173        assert_eq!(decoded.api_version, "v1");
174        assert_eq!(decoded.kind, RENEW_AGENT_CREDENTIAL_KIND);
175
176        // CSR 是必填:缺该字段的报文解不了(不再有「只要 bearer」的双轨报文)。
177        let mut without_csr = serde_json::to_value(&request).expect("encode");
178        without_csr
179            .as_object_mut()
180            .expect("object")
181            .remove("certificate_signing_request");
182        assert!(serde_json::from_value::<CredentialRenewal>(without_csr).is_err());
183    }
184
185    #[test]
186    fn enrollment_status_uses_wire_names_and_rejects_unknown_variants() {
187        for (status, name) in [
188            (EnrollmentStatus::Accepted, "accepted"),
189            (EnrollmentStatus::Rejected, "rejected"),
190            (EnrollmentStatus::PendingReview, "pending_review"),
191        ] {
192            assert_eq!(
193                serde_json::to_string(&status).unwrap(),
194                format!("\"{name}\"")
195            );
196        }
197        assert!(serde_json::from_str::<EnrollmentStatus>("\"unknown\"").is_err());
198    }
199
200    #[test]
201    fn enrollment_request_carries_a_required_csr() {
202        let profile = sample_host_profile();
203        let request = EnrollmentRequest::new(
204            "token-a".to_string(),
205            "csr".to_string(),
206            "-----BEGIN CERTIFICATE REQUEST-----\nA\n-----END CERTIFICATE REQUEST-----\n"
207                .to_string(),
208            profile,
209            "wist-agentd:test".to_string(),
210            "2026-09-28T00:00:00Z".to_string(),
211        );
212        let json = serde_json::to_string(&request).expect("encode");
213        assert!(json.contains("certificate_signing_request"));
214        let back: EnrollmentRequest = serde_json::from_str(&json).expect("decode");
215        assert_eq!(back, request);
216
217        // CSR 必填:缺该字段的报文解不了(注册不再有「不带证书」的退路)。
218        let mut without_csr = serde_json::to_value(&request).expect("encode");
219        without_csr
220            .as_object_mut()
221            .expect("object")
222            .remove("certificate_signing_request");
223        assert!(serde_json::from_value::<EnrollmentRequest>(without_csr).is_err());
224    }
225
226    fn sample_host_profile() -> HostProfile {
227        HostProfile {
228            node_id: "node-1".to_string(),
229            hostname: "host-1".to_string(),
230            os: "linux".to_string(),
231            arch: "x86_64".to_string(),
232            machine_id: "mid-1".to_string(),
233            cloud_instance_id: None,
234            k8s_node_uid: None,
235            ip_addresses: vec!["10.0.0.1".to_string()],
236        }
237    }
238
239    #[test]
240    fn an_outcome_without_optional_payloads_decodes() {
241        // 拒绝的注册只带 status/reason_code:其余 Option 字段缺省即可。
242        let json = r#"{"result":{"status":"rejected","reason_code":"bad_token"}}"#;
243        let envelope: EnrollmentEnvelope = serde_json::from_str(json).expect("decode");
244        assert_eq!(envelope.result.status, EnrollmentStatus::Rejected);
245        assert!(envelope.result.issued_identity.is_none());
246        assert!(envelope.result.credential_bundle.is_none());
247    }
248}