1use 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 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 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 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 #[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 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 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 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 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 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}