use serde::{Deserialize, Serialize};
use crate::API_VERSION_V1;
pub const SUBMIT_ENROLLMENT_REQUEST_KIND: &str = "submit_enrollment_request";
pub const RENEW_AGENT_CREDENTIAL_KIND: &str = "renew_agent_credential";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnrollmentRequest {
pub api_version: String,
pub kind: String,
pub token: String,
pub credential_request: String,
pub certificate_signing_request: String,
pub host_profile: HostProfile,
pub capability_summary: String,
pub requested_at: String,
}
impl EnrollmentRequest {
pub fn new(
token: String,
credential_request: String,
certificate_signing_request: String,
host_profile: HostProfile,
capability_summary: String,
requested_at: String,
) -> Self {
Self {
api_version: API_VERSION_V1.to_string(),
kind: SUBMIT_ENROLLMENT_REQUEST_KIND.to_string(),
token,
credential_request,
certificate_signing_request,
host_profile,
capability_summary,
requested_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
#[serde(deny_unknown_fields)]
pub struct HostProfile {
pub node_id: String,
pub hostname: String,
pub os: String,
pub arch: String,
pub machine_id: String,
pub cloud_instance_id: Option<String>,
pub k8s_node_uid: Option<String>,
pub ip_addresses: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnrollmentEnvelope {
pub result: EnrollmentOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnrollmentOutcome {
pub status: EnrollmentStatus,
pub reason_code: Option<String>,
pub agent_id: Option<String>,
pub instance_id: Option<String>,
pub issued_identity: Option<AgentIdentity>,
pub credential_bundle: Option<CredentialBundle>,
pub initial_config: Option<InitialConfig>,
pub policy_binding: Option<PolicyBinding>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EnrollmentStatus {
#[serde(rename = "accepted")]
Accepted,
#[serde(rename = "rejected")]
Rejected,
#[serde(rename = "pending_review")]
PendingReview,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
#[serde(deny_unknown_fields)]
pub struct AgentIdentity {
pub agent_id: String,
pub instance_id: String,
pub tenant_id: String,
pub environment_id: String,
pub node_id: String,
pub issued_at: String,
pub expires_at: Option<String>,
pub status: AgentIdentityStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "state", domain = "Control", module = "Control.Agent.Identity")]
pub enum AgentIdentityStatus {
#[serde(rename = "active")]
Active,
#[serde(rename = "revoked")]
Revoked,
#[serde(rename = "expired")]
Expired,
#[serde(rename = "renewal_required")]
RenewalRequired,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
#[serde(deny_unknown_fields)]
pub struct CredentialBundle {
pub credential_id: String,
pub agent_id: String,
pub instance_id: String,
pub certificate: String,
pub private_key_ref: Option<String>,
pub ca_bundle: Option<String>,
pub issued_at: String,
pub not_before: Option<String>,
pub not_after: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialRenewal {
pub api_version: String,
pub kind: String,
pub agent_id: String,
pub instance_id: String,
pub credential_request: String,
pub certificate_signing_request: String,
pub requested_at: String,
}
impl CredentialRenewal {
pub fn new(
agent_id: String,
instance_id: String,
credential_request: String,
certificate_signing_request: String,
requested_at: String,
) -> Self {
Self {
api_version: API_VERSION_V1.to_string(),
kind: RENEW_AGENT_CREDENTIAL_KIND.to_string(),
agent_id,
instance_id,
credential_request,
certificate_signing_request,
requested_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialRenewed {
pub credential_bundle: CredentialBundle,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InitialConfig {
pub schema_version: String,
pub mode: String,
pub gateway_endpoint: String,
pub policy_version: String,
pub telemetry_output: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyBinding {
pub agent_id: String,
pub policy_id: String,
pub policy_version: String,
pub bound_at: String,
}
#[cfg(test)]
mod tests {
use super::{
CredentialBundle, CredentialRenewal, EnrollmentEnvelope, EnrollmentOutcome,
EnrollmentRequest, EnrollmentStatus, HostProfile, RENEW_AGENT_CREDENTIAL_KIND,
};
#[test]
fn credential_bundle_carries_only_the_client_certificate() {
let bundle = CredentialBundle {
credential_id: "cred-1".to_string(),
agent_id: "agent-1".to_string(),
instance_id: "inst-1".to_string(),
certificate: "CERT".to_string(),
private_key_ref: None,
ca_bundle: None,
issued_at: "2026-09-30T00:00:00Z".to_string(),
not_before: None,
not_after: None,
};
let json = serde_json::to_string(&bundle).expect("encode");
assert!(!json.contains("bearer_token"), "{json}");
assert!(!json.contains("auth_scheme"), "{json}");
let legacy = r#"{"credential_id":"c","agent_id":"a","instance_id":"i",\
"auth_scheme":"bearer","bearer_token":"wic_x","certificate":"CERT",\
"private_key_ref":null,"ca_bundle":null,"issued_at":"t",\
"not_before":null,"not_after":null}"#;
assert!(serde_json::from_str::<CredentialBundle>(legacy).is_err());
let no_certificate = r#"{"credential_id":"c","agent_id":"a","instance_id":"i",\
"private_key_ref":null,"ca_bundle":null,"issued_at":"t",\
"not_before":null,"not_after":null}"#;
assert!(serde_json::from_str::<CredentialBundle>(no_certificate).is_err());
}
#[test]
fn enrollment_result_status_uses_wire_names() {
let decoded: EnrollmentEnvelope =
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}}"#)
.expect("decode");
assert_eq!(decoded.result.status, EnrollmentStatus::Accepted);
let encoded = serde_json::to_string(&EnrollmentOutcome {
status: EnrollmentStatus::PendingReview,
reason_code: Some("manual_review".to_string()),
agent_id: None,
instance_id: None,
issued_identity: None,
credential_bundle: None,
initial_config: None,
policy_binding: None,
})
.expect("encode");
assert!(encoded.contains("\"pending_review\""));
}
#[test]
fn renew_agent_credential_uses_stable_wire_kind() {
let request = CredentialRenewal::new(
"agent-a".to_string(),
"instance-a".to_string(),
"csr".to_string(),
"-----BEGIN CERTIFICATE REQUEST-----\nA\n".to_string(),
"2026-07-29T00:00:00Z".to_string(),
);
let encoded = serde_json::to_string(&request).expect("encode");
assert!(encoded.contains(&format!("\"kind\":\"{RENEW_AGENT_CREDENTIAL_KIND}\"")));
assert!(encoded.contains("certificate_signing_request"));
let decoded: CredentialRenewal = serde_json::from_str(&encoded).expect("decode");
assert_eq!(decoded.api_version, "v1");
assert_eq!(decoded.kind, RENEW_AGENT_CREDENTIAL_KIND);
let mut without_csr = serde_json::to_value(&request).expect("encode");
without_csr
.as_object_mut()
.expect("object")
.remove("certificate_signing_request");
assert!(serde_json::from_value::<CredentialRenewal>(without_csr).is_err());
}
#[test]
fn enrollment_status_uses_wire_names_and_rejects_unknown_variants() {
for (status, name) in [
(EnrollmentStatus::Accepted, "accepted"),
(EnrollmentStatus::Rejected, "rejected"),
(EnrollmentStatus::PendingReview, "pending_review"),
] {
assert_eq!(
serde_json::to_string(&status).unwrap(),
format!("\"{name}\"")
);
}
assert!(serde_json::from_str::<EnrollmentStatus>("\"unknown\"").is_err());
}
#[test]
fn enrollment_request_carries_a_required_csr() {
let profile = sample_host_profile();
let request = EnrollmentRequest::new(
"token-a".to_string(),
"csr".to_string(),
"-----BEGIN CERTIFICATE REQUEST-----\nA\n-----END CERTIFICATE REQUEST-----\n"
.to_string(),
profile,
"wist-agentd:test".to_string(),
"2026-09-28T00:00:00Z".to_string(),
);
let json = serde_json::to_string(&request).expect("encode");
assert!(json.contains("certificate_signing_request"));
let back: EnrollmentRequest = serde_json::from_str(&json).expect("decode");
assert_eq!(back, request);
let mut without_csr = serde_json::to_value(&request).expect("encode");
without_csr
.as_object_mut()
.expect("object")
.remove("certificate_signing_request");
assert!(serde_json::from_value::<EnrollmentRequest>(without_csr).is_err());
}
fn sample_host_profile() -> HostProfile {
HostProfile {
node_id: "node-1".to_string(),
hostname: "host-1".to_string(),
os: "linux".to_string(),
arch: "x86_64".to_string(),
machine_id: "mid-1".to_string(),
cloud_instance_id: None,
k8s_node_uid: None,
ip_addresses: vec!["10.0.0.1".to_string()],
}
}
#[test]
fn host_profile_round_trips_and_rejects_unknown_fields() {
let profile = HostProfile {
node_id: "node-1".to_string(),
hostname: "host-1".to_string(),
os: "macos".to_string(),
arch: "arm64".to_string(),
machine_id: "mid-1".to_string(),
cloud_instance_id: None,
k8s_node_uid: None,
ip_addresses: vec!["10.0.0.1".to_string()],
};
let json = serde_json::to_string(&profile).expect("encode");
let back: HostProfile = serde_json::from_str(&json).expect("decode");
assert_eq!(back, profile);
let mutated = json.replacen('{', "{\"extra\":1,", 1);
assert!(serde_json::from_str::<HostProfile>(&mutated).is_err());
}
#[test]
fn an_outcome_without_optional_payloads_decodes() {
let json = r#"{"result":{"status":"rejected","reason_code":"bad_token"}}"#;
let envelope: EnrollmentEnvelope = serde_json::from_str(json).expect("decode");
assert_eq!(envelope.result.status, EnrollmentStatus::Rejected);
assert!(envelope.result.issued_identity.is_none());
assert!(envelope.result.credential_bundle.is_none());
}
}