Skip to main content

wist_contracts/
enrollment.rs

1//! Agent enrollment / identity **domain** types.
2//!
3//! 跨进程 seam 的**报文**(`EnrollmentRequest` / `EnrollmentEnvelope` / `EnrollmentOutcome` /
4//! `EnrollmentStatus` / `CredentialRenewal` / `CredentialRenewed`)已迁到独立 seam crate
5//! `wist-api::enrollment`。这里保留的是被多条 seam 复用的**领域类型**(如 `HostProfile` 同时
6//! 用在 `agent/enroll` 与 `agent/status`),故留在两侧共同底座。
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
11#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
12#[serde(deny_unknown_fields)]
13pub struct HostProfile {
14    pub node_id: String,
15    pub hostname: String,
16    pub os: String,
17    pub arch: String,
18    pub machine_id: String,
19    pub cloud_instance_id: Option<String>,
20    pub k8s_node_uid: Option<String>,
21    pub ip_addresses: Vec<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
25#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
26#[serde(deny_unknown_fields)]
27pub struct AgentIdentity {
28    pub agent_id: String,
29    pub instance_id: String,
30    pub tenant_id: String,
31    pub environment_id: String,
32    pub node_id: String,
33    pub issued_at: String,
34    pub expires_at: Option<String>,
35    pub status: AgentIdentityStatus,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
39#[jumo(kind = "state", domain = "Control", module = "Control.Agent.Identity")]
40pub enum AgentIdentityStatus {
41    #[serde(rename = "active")]
42    Active,
43    #[serde(rename = "revoked")]
44    Revoked,
45    #[serde(rename = "expired")]
46    Expired,
47    #[serde(rename = "renewal_required")]
48    RenewalRequired,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
52#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Identity")]
53#[serde(deny_unknown_fields)]
54pub struct CredentialBundle {
55    pub credential_id: String,
56    pub agent_id: String,
57    pub instance_id: String,
58    /// 客户端证书(PEM)。mTLS 是 agent 与网关之间的**唯一**凭据路径(bearer 已删),
59    /// 所以这里是必填:注册/续期都必须换回一张证书。
60    pub certificate: String,
61    pub private_key_ref: Option<String>,
62    pub ca_bundle: Option<String>,
63    pub issued_at: String,
64    pub not_before: Option<String>,
65    pub not_after: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct InitialConfig {
71    pub schema_version: String,
72    pub mode: String,
73    pub gateway_endpoint: String,
74    pub policy_version: String,
75    pub telemetry_output: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct PolicyBinding {
81    pub agent_id: String,
82    pub policy_id: String,
83    pub policy_version: String,
84    pub bound_at: String,
85}
86
87#[cfg(test)]
88mod tests {
89    use super::{CredentialBundle, HostProfile};
90
91    /// 契约收口(2026-09-30):agent 的凭据包**只剩客户端证书** —— 线上不再有 `bearer_token` /
92    /// `auth_scheme`,而 `certificate` 是必填。带旧字段的老报文一律拒(`deny_unknown_fields`)。
93    #[test]
94    fn credential_bundle_carries_only_the_client_certificate() {
95        let bundle = CredentialBundle {
96            credential_id: "cred-1".to_string(),
97            agent_id: "agent-1".to_string(),
98            instance_id: "inst-1".to_string(),
99            certificate: "CERT".to_string(),
100            private_key_ref: None,
101            ca_bundle: None,
102            issued_at: "2026-09-30T00:00:00Z".to_string(),
103            not_before: None,
104            not_after: None,
105        };
106        let json = serde_json::to_string(&bundle).expect("encode");
107        assert!(!json.contains("bearer_token"), "{json}");
108        assert!(!json.contains("auth_scheme"), "{json}");
109
110        // 带旧字段的老报文解不了(字段已从契约里删掉)。
111        let legacy = r#"{"credential_id":"c","agent_id":"a","instance_id":"i",\
112            "auth_scheme":"bearer","bearer_token":"wic_x","certificate":"CERT",\
113            "private_key_ref":null,"ca_bundle":null,"issued_at":"t",\
114            "not_before":null,"not_after":null}"#;
115        assert!(serde_json::from_str::<CredentialBundle>(legacy).is_err());
116
117        // certificate 必填:缺了就解不了(mTLS 是唯一凭据路径,没有它就没有凭据)。
118        let no_certificate = r#"{"credential_id":"c","agent_id":"a","instance_id":"i",\
119            "private_key_ref":null,"ca_bundle":null,"issued_at":"t",\
120            "not_before":null,"not_after":null}"#;
121        assert!(serde_json::from_str::<CredentialBundle>(no_certificate).is_err());
122    }
123
124    #[test]
125    fn host_profile_round_trips_and_rejects_unknown_fields() {
126        let profile = HostProfile {
127            node_id: "node-1".to_string(),
128            hostname: "host-1".to_string(),
129            os: "macos".to_string(),
130            arch: "arm64".to_string(),
131            machine_id: "mid-1".to_string(),
132            cloud_instance_id: None,
133            k8s_node_uid: None,
134            ip_addresses: vec!["10.0.0.1".to_string()],
135        };
136        let json = serde_json::to_string(&profile).expect("encode");
137        let back: HostProfile = serde_json::from_str(&json).expect("decode");
138        assert_eq!(back, profile);
139
140        let mutated = json.replacen('{', "{\"extra\":1,", 1);
141        assert!(serde_json::from_str::<HostProfile>(&mutated).is_err());
142    }
143}