1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(tag = "method", rename_all = "snake_case")]
8pub enum AuthMethod {
9 Bearer {
11 token_hash: String,
13 },
14 ApiKey {
16 key_name: String,
18 key_hash: String,
20 },
21 Cookie {
23 cookie_name: String,
25 cookie_hash: String,
27 },
28 MtlsCertificate {
30 subject_dn: String,
32 fingerprint: String,
34 },
35 Anonymous,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct CallerIdentity {
45 pub subject: String,
48
49 pub auth_method: AuthMethod,
51
52 #[serde(default)]
56 pub verified: bool,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub tenant: Option<String>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub agent_id: Option<String>,
65}
66
67impl CallerIdentity {
68 #[must_use]
70 pub fn anonymous() -> Self {
71 Self {
72 subject: "anonymous".to_string(),
73 auth_method: AuthMethod::Anonymous,
74 verified: false,
75 tenant: None,
76 agent_id: None,
77 }
78 }
79
80 pub fn identity_hash(&self) -> chio_core_types::Result<String> {
83 let bytes = chio_core_types::canonical_json_bytes(self)?;
84 Ok(chio_core_types::sha256_hex(&bytes))
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 use chio_test_support::prelude::*;
93
94 #[test]
95 fn anonymous_identity() {
96 let id = CallerIdentity::anonymous();
97 assert_eq!(id.subject, "anonymous");
98 assert!(!id.verified);
99 assert!(matches!(id.auth_method, AuthMethod::Anonymous));
100 }
101
102 #[test]
103 fn identity_hash_deterministic() {
104 let id = CallerIdentity {
105 subject: "user-123".to_string(),
106 auth_method: AuthMethod::Bearer {
107 token_hash: "abc123".to_string(),
108 },
109 verified: true,
110 tenant: Some("acme".to_string()),
111 agent_id: None,
112 };
113 let h1 = id.identity_hash().test_unwrap();
114 let h2 = id.identity_hash().test_unwrap();
115 assert_eq!(h1, h2);
116 assert_eq!(h1.len(), 64); }
118
119 #[test]
120 fn serde_roundtrip() {
121 let id = CallerIdentity {
122 subject: "svc-agent".to_string(),
123 auth_method: AuthMethod::ApiKey {
124 key_name: "X-API-Key".to_string(),
125 key_hash: "deadbeef".to_string(),
126 },
127 verified: true,
128 tenant: None,
129 agent_id: Some("agent-42".to_string()),
130 };
131 let json = serde_json::to_string(&id).test_unwrap();
132 let back: CallerIdentity = serde_json::from_str(&json).test_unwrap();
133 assert_eq!(back.subject, "svc-agent");
134 assert_eq!(back.agent_id.as_deref(), Some("agent-42"));
135 }
136
137 #[test]
138 fn mtls_certificate_serde_roundtrip() {
139 let id = CallerIdentity {
140 subject: "CN=service.internal".to_string(),
141 auth_method: AuthMethod::MtlsCertificate {
142 subject_dn: "CN=service.internal,O=Acme".to_string(),
143 fingerprint: "abcdef1234567890".to_string(),
144 },
145 verified: true,
146 tenant: Some("acme-corp".to_string()),
147 agent_id: None,
148 };
149 let json = serde_json::to_string(&id).test_unwrap();
150 let back: CallerIdentity = serde_json::from_str(&json).test_unwrap();
151 assert_eq!(back.subject, "CN=service.internal");
152 assert!(back.verified);
153 assert_eq!(back.tenant.as_deref(), Some("acme-corp"));
154 match &back.auth_method {
155 AuthMethod::MtlsCertificate {
156 subject_dn,
157 fingerprint,
158 } => {
159 assert_eq!(subject_dn, "CN=service.internal,O=Acme");
160 assert_eq!(fingerprint, "abcdef1234567890");
161 }
162 other => panic!("expected MtlsCertificate, got {other:?}"),
163 }
164 }
165
166 #[test]
167 fn cookie_auth_method_serde_roundtrip() {
168 let id = CallerIdentity {
169 subject: "cookie-user".to_string(),
170 auth_method: AuthMethod::Cookie {
171 cookie_name: "session_id".to_string(),
172 cookie_hash: "cookiehash123".to_string(),
173 },
174 verified: false,
175 tenant: None,
176 agent_id: None,
177 };
178 let json = serde_json::to_string(&id).test_unwrap();
179 let back: CallerIdentity = serde_json::from_str(&json).test_unwrap();
180 match &back.auth_method {
181 AuthMethod::Cookie {
182 cookie_name,
183 cookie_hash,
184 } => {
185 assert_eq!(cookie_name, "session_id");
186 assert_eq!(cookie_hash, "cookiehash123");
187 }
188 other => panic!("expected Cookie, got {other:?}"),
189 }
190 }
191
192 #[test]
193 fn different_identities_produce_different_hashes() {
194 let id1 = CallerIdentity {
195 subject: "user-a".to_string(),
196 auth_method: AuthMethod::Bearer {
197 token_hash: "hash1".to_string(),
198 },
199 verified: true,
200 tenant: None,
201 agent_id: None,
202 };
203 let id2 = CallerIdentity {
204 subject: "user-b".to_string(),
205 auth_method: AuthMethod::Bearer {
206 token_hash: "hash2".to_string(),
207 },
208 verified: true,
209 tenant: None,
210 agent_id: None,
211 };
212 let h1 = id1.identity_hash().test_unwrap();
213 let h2 = id2.identity_hash().test_unwrap();
214 assert_ne!(h1, h2);
215 }
216
217 #[test]
218 fn anonymous_identity_serde_omits_optional_fields() {
219 let id = CallerIdentity::anonymous();
220 let json = serde_json::to_string(&id).test_unwrap();
221 assert!(!json.contains("tenant"));
223 assert!(!json.contains("agent_id"));
224 }
225}