Skip to main content

axess_core/principal/
cedar.rs

1//! Cedar bridge for [`Principal`].
2//!
3//! Emit `cedar_policy::Entity` values for human and workload
4//! principals so adopter authorization policies can reason about them
5//! symmetrically. Entity type names live under a stable `axess`
6//! namespace (`axess::Human`, `axess::Workload`); adopter schemas
7//! extend with their own resource entity types.
8//!
9//! [`Principal`] is defined in the leaf [`axess_identity`] crate so
10//! the Cedar surface lives behind a [`ToCedarEntity`] trait here
11//! instead of inherent methods. Call sites bring the trait into scope:
12//!
13//! ```ignore
14//! use axess_core::ToCedarEntity;
15//! let entity = principal.to_cedar_entity()?;
16//! ```
17//!
18//! # Attribute shapes
19//!
20//! `axess::Human`:
21//! - `user_id`: string
22//! - `tenant_id`: string
23//! - `session_id`: string (only present when the principal carries one)
24//! - any extra keys from [`HumanPrincipal::attributes`] map onto Cedar
25//!   typed values via the JSON-to-Cedar rules documented on
26//!   [`json_to_restricted_expression`].
27//!
28//! `axess::Workload`:
29//! - `workload_id`: string (the SPIFFE URI)
30//! - `trust_domain`: string
31//! - `issuer`: string (lowercase variant name; `"cli"` today)
32//! - `tenant_id`: string
33//! - `tenant_slug`: string
34//! - `service_name`: string
35//! - extras from [`WorkloadPrincipal::attributes`] per the same rules
36//!
37//! # Conflict policy
38//!
39//! When [`HumanPrincipal::attributes`] or [`WorkloadPrincipal::attributes`]
40//! contains a key that collides with a built-in attribute name
41//! (e.g. `tenant_id`), the user-supplied attribute wins. Adopters who
42//! explicitly override an attribute have a reason; a silent built-in
43//! win would surprise them.
44
45use 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
52/// Cedar namespace used for axess-emitted principal entities.
53pub const PRINCIPAL_NAMESPACE: &str = "axess";
54
55/// Cedar entity type name for human principals.
56pub const HUMAN_ENTITY_TYPE: &str = "Human";
57
58/// Cedar entity type name for workload principals.
59pub const WORKLOAD_ENTITY_TYPE: &str = "Workload";
60
61/// Trait surface for converting a [`Principal`] (or one of its
62/// variants) into a `cedar_policy::Entity`. Bring this into scope to
63/// call `to_cedar_entity()` on principals.
64pub trait ToCedarEntity {
65    /// Build the Cedar [`EntityUid`] for this principal without
66    /// constructing the full entity. Useful when the caller only
67    /// needs the UID for an authorization request (entity attributes
68    /// supplied separately via an
69    /// [`AuthzEntityProvider`](crate::AuthzEntityProvider)).
70    fn cedar_entity_uid(&self) -> Result<EntityUid, AuthzError>;
71
72    /// Build the full Cedar [`Entity`] for this principal: UID plus
73    /// attribute record.
74    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
167/// Apply user-supplied attributes onto the built-in attribute map,
168/// converting each `serde_json::Value` per
169/// [`json_to_restricted_expression`]. User keys override built-in
170/// keys on collision (see module-level "Conflict policy").
171fn 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
180/// Map a [`serde_json::Value`] onto a Cedar [`RestrictedExpression`].
181///
182/// Conversion rules:
183/// - `Bool` → Cedar bool
184/// - `String` → Cedar string
185/// - `Number` → Cedar long when it fits in `i64`; string otherwise
186///   (Cedar has no native floating-point or arbitrary-precision type)
187/// - `Null`, `Array`, `Object` → JSON-encoded string
188///
189/// The string fallback for non-scalar shapes preserves the data so
190/// adopter policies can `contains(...)`-test it; future iterations
191/// may map arrays onto Cedar sets natively when a concrete use case
192/// demands it.
193pub 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}