1use std::collections::{HashMap, HashSet};
46
47use axess_identity::{HumanPrincipal, Principal, WorkloadPrincipal};
48use cedar_policy::{Entity, EntityUid, RestrictedExpression};
49
50use crate::authz::{AuthzError, make_entity_uid};
51
52pub const PRINCIPAL_NAMESPACE: &str = "axess";
54
55pub const HUMAN_ENTITY_TYPE: &str = "Human";
57
58pub const WORKLOAD_ENTITY_TYPE: &str = "Workload";
60
61pub trait ToCedarEntity {
65 fn cedar_entity_uid(&self) -> Result<EntityUid, AuthzError>;
71
72 fn to_cedar_entity(&self) -> Result<Entity, AuthzError>;
75}
76
77impl ToCedarEntity for Principal {
78 fn cedar_entity_uid(&self) -> Result<EntityUid, AuthzError> {
79 match self {
80 Self::Human(h) => h.cedar_entity_uid(),
81 Self::Workload(w) => w.cedar_entity_uid(),
82 }
83 }
84
85 fn to_cedar_entity(&self) -> Result<Entity, AuthzError> {
86 match self {
87 Self::Human(h) => h.to_cedar_entity(),
88 Self::Workload(w) => w.to_cedar_entity(),
89 }
90 }
91}
92
93impl ToCedarEntity for HumanPrincipal {
94 fn cedar_entity_uid(&self) -> Result<EntityUid, AuthzError> {
95 make_entity_uid(
96 PRINCIPAL_NAMESPACE,
97 HUMAN_ENTITY_TYPE,
98 &self.user_id.to_string(),
99 )
100 }
101
102 fn to_cedar_entity(&self) -> Result<Entity, AuthzError> {
103 let uid = self.cedar_entity_uid()?;
104 let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
105 attrs.insert(
106 "user_id".to_string(),
107 RestrictedExpression::new_string(self.user_id.to_string()),
108 );
109 attrs.insert(
110 "tenant_id".to_string(),
111 RestrictedExpression::new_string(self.tenant_id.to_string()),
112 );
113 if let Some(sid) = &self.session_id {
114 attrs.insert(
115 "session_id".to_string(),
116 RestrictedExpression::new_string(sid.to_string()),
117 );
118 }
119 merge_attribute_map(&mut attrs, &self.attributes);
120 Entity::new(uid, attrs, HashSet::new())
121 .map_err(|e| AuthzError::EntityBuild(format!("HumanPrincipal: {e:?}")))
122 }
123}
124
125impl ToCedarEntity for WorkloadPrincipal {
126 fn cedar_entity_uid(&self) -> Result<EntityUid, AuthzError> {
127 make_entity_uid(
128 PRINCIPAL_NAMESPACE,
129 WORKLOAD_ENTITY_TYPE,
130 self.workload_id.as_str(),
131 )
132 }
133
134 fn to_cedar_entity(&self) -> Result<Entity, AuthzError> {
135 let uid = self.cedar_entity_uid()?;
136 let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
137 attrs.insert(
138 "workload_id".to_string(),
139 RestrictedExpression::new_string(self.workload_id.as_str().to_string()),
140 );
141 attrs.insert(
142 "trust_domain".to_string(),
143 RestrictedExpression::new_string(self.trust_domain.as_str().to_string()),
144 );
145 attrs.insert(
146 "issuer".to_string(),
147 RestrictedExpression::new_string(self.issuer.as_str().to_string()),
148 );
149 attrs.insert(
150 "tenant_id".to_string(),
151 RestrictedExpression::new_string(self.tenant_id.to_string()),
152 );
153 attrs.insert(
154 "tenant_slug".to_string(),
155 RestrictedExpression::new_string(self.tenant_slug.clone()),
156 );
157 attrs.insert(
158 "service_name".to_string(),
159 RestrictedExpression::new_string(self.service_name.clone()),
160 );
161 merge_attribute_map(&mut attrs, &self.attributes);
162 Entity::new(uid, attrs, HashSet::new())
163 .map_err(|e| AuthzError::EntityBuild(format!("WorkloadPrincipal: {e:?}")))
164 }
165}
166
167fn merge_attribute_map(
172 attrs: &mut HashMap<String, RestrictedExpression>,
173 user: &std::collections::BTreeMap<String, serde_json::Value>,
174) {
175 for (k, v) in user {
176 attrs.insert(k.clone(), json_to_restricted_expression(v));
177 }
178}
179
180pub fn json_to_restricted_expression(v: &serde_json::Value) -> RestrictedExpression {
194 match v {
195 serde_json::Value::Bool(b) => RestrictedExpression::new_bool(*b),
196 serde_json::Value::String(s) => RestrictedExpression::new_string(s.clone()),
197 serde_json::Value::Number(n) => {
198 if let Some(i) = n.as_i64() {
199 RestrictedExpression::new_long(i)
200 } else {
201 RestrictedExpression::new_string(n.to_string())
202 }
203 }
204 serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
205 RestrictedExpression::new_string(v.to_string())
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use std::collections::BTreeMap;
213
214 use super::*;
215 use axess_identity::{Issuer, TrustDomain, WorkloadId};
216 use axess_identity::{TenantId, UserId};
217
218 fn sample_human() -> HumanPrincipal {
219 HumanPrincipal {
220 user_id: UserId::from_bytes([10u8; 16]),
221 tenant_id: TenantId::from_bytes([20u8; 16]),
222 session_id: None,
223 attributes: BTreeMap::new(),
224 }
225 }
226
227 fn sample_workload() -> WorkloadPrincipal {
228 let trust = TrustDomain::new("gnomes.local").unwrap();
229 let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
230 WorkloadPrincipal {
231 workload_id: wid,
232 trust_domain: trust,
233 issuer: Issuer::Cli,
234 tenant_id: TenantId::from_bytes([30u8; 16]),
235 tenant_slug: "ekekrantz".to_string(),
236 service_name: "compute-worker".to_string(),
237 attributes: BTreeMap::new(),
238 }
239 }
240
241 #[test]
242 fn human_entity_uid_is_namespaced_under_axess_human() {
243 let uid = sample_human().cedar_entity_uid().unwrap();
244 let s = format!("{uid}");
245 assert!(s.starts_with("axess::Human::"), "actual: {s}");
246 }
247
248 #[test]
249 fn workload_entity_uid_is_namespaced_under_axess_workload() {
250 let uid = sample_workload().cedar_entity_uid().unwrap();
251 let s = format!("{uid}");
252 assert!(s.starts_with("axess::Workload::"), "actual: {s}");
253 assert!(s.contains("spiffe://gnomes.local/compute-worker/ekekrantz"));
254 }
255
256 #[test]
257 fn human_to_cedar_entity_includes_required_attrs() {
258 let h = sample_human();
259 let entity = h.to_cedar_entity().unwrap();
260 assert!(entity.attr("user_id").is_some());
261 assert!(entity.attr("tenant_id").is_some());
262 assert!(
263 entity.attr("session_id").is_none(),
264 "session_id absent when principal has none"
265 );
266 }
267
268 #[test]
269 fn human_to_cedar_entity_includes_session_id_when_present() {
270 let mut h = sample_human();
271 h.session_id = Some(axess_identity::SessionId::from_bytes([7u8; 16]));
272 let entity = h.to_cedar_entity().unwrap();
273 assert!(entity.attr("session_id").is_some());
274 }
275
276 #[test]
277 fn workload_to_cedar_entity_includes_all_built_in_attrs() {
278 let entity = sample_workload().to_cedar_entity().unwrap();
279 for name in [
280 "workload_id",
281 "trust_domain",
282 "issuer",
283 "tenant_id",
284 "tenant_slug",
285 "service_name",
286 ] {
287 assert!(
288 entity.attr(name).is_some(),
289 "missing built-in attribute '{name}'"
290 );
291 }
292 }
293
294 #[test]
295 fn user_supplied_attributes_override_built_ins_on_collision() {
296 let mut h = sample_human();
297 h.attributes.insert(
298 "tenant_id".to_string(),
299 serde_json::json!("override-tenant"),
300 );
301 let entity = h.to_cedar_entity().unwrap();
302 assert!(entity.attr("tenant_id").is_some());
303 }
304
305 #[test]
306 fn principal_enum_dispatches_to_variant_method() {
307 let p = Principal::Human(sample_human());
308 let uid = p.cedar_entity_uid().unwrap();
309 assert!(format!("{uid}").starts_with("axess::Human::"));
310
311 let p = Principal::Workload(sample_workload());
312 let uid = p.cedar_entity_uid().unwrap();
313 assert!(format!("{uid}").starts_with("axess::Workload::"));
314 }
315
316 #[test]
317 fn json_to_restricted_handles_scalars() {
318 let mut w = sample_workload();
319 w.attributes
320 .insert("flag".to_string(), serde_json::json!(true));
321 w.attributes
322 .insert("name".to_string(), serde_json::json!("worker"));
323 w.attributes
324 .insert("count".to_string(), serde_json::json!(42));
325 w.attributes
326 .insert("ratio".to_string(), serde_json::json!(1.5));
327 w.attributes
328 .insert("missing".to_string(), serde_json::json!(null));
329 w.attributes
330 .insert("tags".to_string(), serde_json::json!(["a", "b"]));
331 let entity = w.to_cedar_entity().unwrap();
332 for name in ["flag", "name", "count", "ratio", "missing", "tags"] {
333 assert!(
334 entity.attr(name).is_some(),
335 "missing attribute '{name}' after JSON-to-Cedar conversion"
336 );
337 }
338 }
339}