Skip to main content

chronix_security/authz/
model.rs

1//! Chronix authorization model types.
2//!
3//! Defines the actions, resources, and principals used for Cedar policy
4//! evaluation.
5//!
6//! Entity type names are parsed once and cached via `LazyLock` to avoid
7//! per-request `from_str` + `expect()` overhead on the hot path.
8
9use std::collections::{BTreeMap, HashMap, HashSet};
10use std::str::FromStr;
11use std::sync::LazyLock;
12
13use cedar_policy::{Entity, EntityId, EntityTypeName, EntityUid, RestrictedExpression};
14use serde::{Deserialize, Serialize};
15
16use crate::authz::error::AuthzError;
17
18// ── Cached entity type names ────────────────────────────────────────
19
20/// Pre-parsed entity type name for `Chronix::Action`.
21static TYPE_ACTION: LazyLock<EntityTypeName> =
22    LazyLock::new(|| EntityTypeName::from_str("Chronix::Action").expect("valid type"));
23
24/// Pre-parsed entity type name for `Chronix::User`.
25static TYPE_USER: LazyLock<EntityTypeName> =
26    LazyLock::new(|| EntityTypeName::from_str("Chronix::User").expect("valid type"));
27
28/// Pre-parsed entity type name for `Chronix::Role`.
29static TYPE_ROLE: LazyLock<EntityTypeName> =
30    LazyLock::new(|| EntityTypeName::from_str("Chronix::Role").expect("valid type"));
31
32/// Pre-parsed entity type name for `Chronix::Measurement`.
33static TYPE_MEASUREMENT: LazyLock<EntityTypeName> =
34    LazyLock::new(|| EntityTypeName::from_str("Chronix::Measurement").expect("valid type"));
35
36/// Pre-parsed entity type name for `Chronix::Namespace`.
37static TYPE_NAMESPACE: LazyLock<EntityTypeName> =
38    LazyLock::new(|| EntityTypeName::from_str("Chronix::Namespace").expect("valid type"));
39
40/// Returns the cached `Chronix::Role` entity type name.
41pub(crate) fn role_type_name() -> &'static EntityTypeName {
42    &TYPE_ROLE
43}
44
45// ── Actions ─────────────────────────────────────────────────────────
46
47/// Actions that can be authorized in Chronix.
48///
49/// The original `Admin` variant was a catch-all for all
50/// administrative operations.  Granular admin actions (below) allow
51/// policies to grant least-privilege access — e.g. granting
52/// `ManageModels` without also granting `ManageKeys`.  The `Admin`
53/// variant is retained as a super-action that implies all granular
54/// admin actions.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ChronixAction {
58    /// Write data points.
59    Write,
60    /// Read / query data.
61    Read,
62    /// Delete data (series or measurement).
63    Delete,
64    /// Super-admin — implies all granular admin actions below.
65    Admin,
66    /// Create or manage rollups.
67    CreateRollup,
68    /// Execute forecast queries.
69    Forecast,
70    /// Execute anomaly detection.
71    DetectAnomalies,
72    /// Subscribe to CDC events.
73    Subscribe,
74
75    // ── granular admin actions ──────────────────────────────
76    /// Manage cluster nodes (register, decommission, heartbeat).
77    ManageNodes,
78    /// Manage regions (create, state transitions).
79    ManageRegions,
80    /// View cluster topology and health (read-only admin).
81    ViewCluster,
82    /// Trigger cluster rebalancing.
83    ManageCluster,
84    /// Manage analytics models (list, delete, retrain).
85    ManageModels,
86    /// Manage API keys (create, list, revoke).
87    ManageKeys,
88    /// Manage runtime configuration (e.g. log levels).
89    ManageConfig,
90    /// Manage backups and point-in-time restores.
91    ManageBackups,
92    /// Create, list, delete namespaces and view usage.
93    ManageNamespaces,
94}
95
96impl ChronixAction {
97    /// Convert to the Cedar action entity UID.
98    #[must_use]
99    pub fn to_entity_uid(&self) -> EntityUid {
100        let name = match self {
101            Self::Write => "Write",
102            Self::Read => "Read",
103            Self::Delete => "Delete",
104            Self::Admin => "Admin",
105            Self::CreateRollup => "CreateRollup",
106            Self::Forecast => "Forecast",
107            Self::DetectAnomalies => "DetectAnomalies",
108            Self::Subscribe => "Subscribe",
109            Self::ManageNodes => "ManageNodes",
110            Self::ManageRegions => "ManageRegions",
111            Self::ViewCluster => "ViewCluster",
112            Self::ManageCluster => "ManageCluster",
113            Self::ManageModels => "ManageModels",
114            Self::ManageKeys => "ManageKeys",
115            Self::ManageConfig => "ManageConfig",
116            Self::ManageBackups => "ManageBackups",
117            Self::ManageNamespaces => "ManageNamespaces",
118        };
119        EntityUid::from_type_name_and_id(TYPE_ACTION.clone(), EntityId::new(name))
120    }
121
122    /// Returns `true` if this is a granular admin action (or `Admin` itself).
123    ///
124    /// Useful for authorization engines that want to grant `Admin` as a
125    /// super-action implying all granular admin sub-actions.
126    #[must_use]
127    pub fn is_admin_action(&self) -> bool {
128        matches!(
129            self,
130            Self::Admin
131                | Self::ManageNodes
132                | Self::ManageRegions
133                | Self::ViewCluster
134                | Self::ManageCluster
135                | Self::ManageModels
136                | Self::ManageKeys
137                | Self::ManageConfig
138                | Self::ManageBackups
139                | Self::ManageNamespaces
140        )
141    }
142}
143
144impl std::fmt::Display for ChronixAction {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        let s = match self {
147            Self::Write => "Write",
148            Self::Read => "Read",
149            Self::Delete => "Delete",
150            Self::Admin => "Admin",
151            Self::CreateRollup => "CreateRollup",
152            Self::Forecast => "Forecast",
153            Self::DetectAnomalies => "DetectAnomalies",
154            Self::Subscribe => "Subscribe",
155            Self::ManageNodes => "ManageNodes",
156            Self::ManageRegions => "ManageRegions",
157            Self::ViewCluster => "ViewCluster",
158            Self::ManageCluster => "ManageCluster",
159            Self::ManageModels => "ManageModels",
160            Self::ManageKeys => "ManageKeys",
161            Self::ManageConfig => "ManageConfig",
162            Self::ManageBackups => "ManageBackups",
163            Self::ManageNamespaces => "ManageNamespaces",
164        };
165        write!(f, "{s}")
166    }
167}
168
169// ── Resource ────────────────────────────────────────────────────────
170
171/// A Chronix resource subject to authorization.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ChronixResource {
174    /// Measurement name.
175    pub measurement: String,
176    /// Optional namespace the measurement belongs to.
177    ///
178    /// When set, the Cedar entity for this resource will have the
179    /// corresponding `Chronix::Namespace` as a parent, enabling
180    /// namespace-scoped policies such as:
181    ///
182    /// ```cedar
183    /// permit(
184    ///   principal in Chronix::Role::"ns_reader",
185    ///   action == Chronix::Action::"Read",
186    ///   resource in Chronix::Namespace::"production"
187    /// );
188    /// ```
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub namespace: Option<String>,
191    /// Optional tag constraints (for fine-grained access).
192    #[serde(default)]
193    pub tags: BTreeMap<String, String>,
194}
195
196impl ChronixResource {
197    /// Create a new resource for a measurement.
198    #[must_use]
199    pub fn measurement(name: impl Into<String>) -> Self {
200        Self {
201            measurement: name.into(),
202            namespace: None,
203            tags: BTreeMap::new(),
204        }
205    }
206
207    /// Set the namespace this measurement belongs to.
208    ///
209    /// This causes the measurement entity to have the namespace as a
210    /// Cedar parent, enabling `resource in Chronix::Namespace::"..."` policies.
211    #[must_use]
212    pub fn with_namespace(mut self, ns: impl Into<String>) -> Self {
213        self.namespace = Some(ns.into());
214        self
215    }
216
217    /// Maximum number of tags allowed on a resource entity.
218    /// Prevents Cedar entity attribute explosion on high-cardinality tags.
219    const MAX_TAGS: usize = 64;
220
221    /// Add a tag constraint.
222    #[must_use]
223    pub fn with_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
224        if self.tags.len() < Self::MAX_TAGS {
225            self.tags.insert(key.into(), value.into());
226        } else {
227            tracing::warn!(
228                max = Self::MAX_TAGS,
229                "tag limit reached on resource entity; \
230                 additional tags are ignored for Cedar evaluation"
231            );
232        }
233        self
234    }
235
236    /// Convert to a Cedar entity.
237    ///
238    /// If a namespace is set, the namespace entity is included as a
239    /// parent so that `resource in Chronix::Namespace::"..."` policies
240    /// evaluate correctly.
241    pub fn to_entity(&self) -> Result<Entity, AuthzError> {
242        let uid = EntityUid::from_type_name_and_id(
243            TYPE_MEASUREMENT.clone(),
244            EntityId::new(&self.measurement),
245        );
246
247        let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
248        attrs.insert(
249            "name".into(),
250            RestrictedExpression::new_string(self.measurement.clone()),
251        );
252
253        // Add tags as a record attribute
254        if !self.tags.is_empty() {
255            let tag_pairs: Vec<(String, RestrictedExpression)> = self
256                .tags
257                .iter()
258                .map(|(k, v)| (k.clone(), RestrictedExpression::new_string(v.clone())))
259                .collect();
260            attrs.insert(
261                "tags".into(),
262                RestrictedExpression::new_record(tag_pairs)
263                    .map_err(|e| AuthzError::Internal(format!("record construction: {e}")))?,
264            );
265        }
266
267        // Add namespace as a parent entity for hierarchical policies.
268        let mut parents = HashSet::new();
269        if let Some(ns) = &self.namespace {
270            attrs.insert(
271                "namespace".into(),
272                RestrictedExpression::new_string(ns.clone()),
273            );
274            parents.insert(EntityUid::from_type_name_and_id(
275                TYPE_NAMESPACE.clone(),
276                EntityId::new(ns),
277            ));
278        }
279
280        Entity::new(uid, attrs, parents)
281            .map_err(|e| AuthzError::Internal(format!("Measurement entity: {e}")))
282    }
283
284    /// Build the namespace parent entity (if set).
285    ///
286    /// Call this to include in the Cedar entity set so the authorizer
287    /// can resolve the parent relationship.
288    pub fn namespace_entity(&self) -> Option<Entity> {
289        self.namespace
290            .as_ref()
291            .and_then(|ns| ChronixNamespace::new(ns).to_entity().ok())
292    }
293
294    /// Convert to the Cedar entity UID.
295    #[must_use]
296    pub fn to_entity_uid(&self) -> EntityUid {
297        EntityUid::from_type_name_and_id(TYPE_MEASUREMENT.clone(), EntityId::new(&self.measurement))
298    }
299}
300
301// ── Namespace resource ──────────────────────────────────────────────
302
303/// A Chronix namespace subject to authorization.
304///
305/// Cedar policies can reference `Chronix::Namespace` entities with
306/// a `name` attribute, enabling rules like:
307///
308/// ```cedar
309/// permit(
310///   principal in Chronix::Role::"ns_admin",
311///   action,
312///   resource == Chronix::Namespace::"production"
313/// );
314/// ```
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct ChronixNamespace {
317    /// Namespace identifier.
318    pub name: String,
319}
320
321impl ChronixNamespace {
322    /// Create a namespace resource.
323    #[must_use]
324    pub fn new(name: impl Into<String>) -> Self {
325        Self { name: name.into() }
326    }
327
328    /// Convert to a Cedar entity.
329    pub fn to_entity(&self) -> Result<Entity, AuthzError> {
330        let uid =
331            EntityUid::from_type_name_and_id(TYPE_NAMESPACE.clone(), EntityId::new(&self.name));
332
333        let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
334        attrs.insert(
335            "name".into(),
336            RestrictedExpression::new_string(self.name.clone()),
337        );
338
339        Entity::new(uid, attrs, HashSet::new())
340            .map_err(|e| AuthzError::Internal(format!("Namespace entity: {e}")))
341    }
342
343    /// Convert to the Cedar entity UID.
344    #[must_use]
345    pub fn to_entity_uid(&self) -> EntityUid {
346        EntityUid::from_type_name_and_id(TYPE_NAMESPACE.clone(), EntityId::new(&self.name))
347    }
348}
349
350// ── Principal ───────────────────────────────────────────────────────
351
352/// A Chronix principal (authenticated identity).
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354pub struct ChronixPrincipal {
355    /// The principal identifier (e.g., API key name, JWT subject, cert CN).
356    pub id: String,
357    /// Roles assigned to this principal.
358    #[serde(default)]
359    pub roles: Vec<String>,
360    /// Namespace the principal belongs to (for cross-tenant isolation).
361    /// When set, the Cedar cross-tenant isolation policy will forbid access
362    /// to resources in a different namespace unless the principal is an admin.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub namespace: Option<String>,
365}
366
367impl ChronixPrincipal {
368    /// Create a new principal.
369    #[must_use]
370    pub fn new(id: impl Into<String>) -> Self {
371        Self {
372            id: id.into(),
373            roles: Vec::new(),
374            namespace: None,
375        }
376    }
377
378    /// Add a role to the principal.
379    #[must_use]
380    pub fn with_role(mut self, role: impl Into<String>) -> Self {
381        self.roles.push(role.into());
382        self
383    }
384
385    /// Set the namespace for this principal (for cross-tenant isolation).
386    #[must_use]
387    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
388        self.namespace = Some(namespace.into());
389        self
390    }
391
392    /// Convert to a Cedar entity (includes role membership as parents).
393    pub fn to_entity(&self) -> Result<Entity, AuthzError> {
394        let uid = EntityUid::from_type_name_and_id(TYPE_USER.clone(), EntityId::new(&self.id));
395
396        let parents: HashSet<EntityUid> = self
397            .roles
398            .iter()
399            .map(|role| EntityUid::from_type_name_and_id(TYPE_ROLE.clone(), EntityId::new(role)))
400            .collect();
401
402        let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
403        attrs.insert(
404            "name".into(),
405            RestrictedExpression::new_string(self.id.clone()),
406        );
407
408        // Include namespace attribute so the cross-tenant isolation
409        // Cedar policy (`principal has namespace`) can evaluate correctly.
410        if let Some(ref ns) = self.namespace {
411            attrs.insert(
412                "namespace".into(),
413                RestrictedExpression::new_string(ns.clone()),
414            );
415        }
416
417        Entity::new(uid, attrs, parents)
418            .map_err(|e| AuthzError::Internal(format!("User entity: {e}")))
419    }
420
421    /// Convert to the Cedar entity UID.
422    #[must_use]
423    pub fn to_entity_uid(&self) -> EntityUid {
424        EntityUid::from_type_name_and_id(TYPE_USER.clone(), EntityId::new(&self.id))
425    }
426}
427
428// ── Decision ────────────────────────────────────────────────────────
429
430/// The result of an authorization decision.
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432pub enum Decision {
433    /// Access is allowed.
434    Allow,
435    /// Access is denied.
436    Deny {
437        /// Human-readable reasons for the denial.
438        reasons: Vec<String>,
439    },
440}
441
442impl Decision {
443    /// Returns `true` if the decision is `Allow`.
444    #[must_use]
445    pub fn is_allowed(&self) -> bool {
446        matches!(self, Self::Allow)
447    }
448
449    /// Returns `true` if the decision is `Deny`.
450    #[must_use]
451    pub fn is_denied(&self) -> bool {
452        !self.is_allowed()
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn action_display() {
462        assert_eq!(ChronixAction::Write.to_string(), "Write");
463        assert_eq!(
464            ChronixAction::DetectAnomalies.to_string(),
465            "DetectAnomalies"
466        );
467    }
468
469    #[test]
470    fn action_to_entity_uid() {
471        let uid = ChronixAction::Read.to_entity_uid();
472        assert!(uid.to_string().contains("Read"));
473    }
474
475    #[test]
476    fn resource_builder() {
477        let res = ChronixResource::measurement("cpu").with_tag("host", "server-01");
478        assert_eq!(res.measurement, "cpu");
479        assert_eq!(res.tags.get("host").unwrap(), "server-01");
480    }
481
482    #[test]
483    fn resource_to_entity() {
484        let res = ChronixResource::measurement("cpu");
485        let entity = res.to_entity().unwrap();
486        assert!(entity.uid().to_string().contains("cpu"));
487    }
488
489    #[test]
490    fn principal_builder() {
491        let p = ChronixPrincipal::new("alice")
492            .with_role("admin")
493            .with_role("data-science");
494        assert_eq!(p.id, "alice");
495        assert_eq!(p.roles.len(), 2);
496    }
497
498    #[test]
499    fn principal_to_entity() {
500        let p = ChronixPrincipal::new("bob").with_role("reader");
501        let entity = p.to_entity().unwrap();
502        assert!(entity.uid().to_string().contains("bob"));
503    }
504
505    #[test]
506    fn decision_is_allowed() {
507        assert!(Decision::Allow.is_allowed());
508        assert!(!Decision::Deny { reasons: vec![] }.is_allowed());
509    }
510
511    #[test]
512    fn decision_is_denied() {
513        assert!(Decision::Deny {
514            reasons: vec!["no policy".into()]
515        }
516        .is_denied());
517        assert!(!Decision::Allow.is_denied());
518    }
519
520    #[test]
521    fn action_serde_roundtrip() {
522        let action = ChronixAction::Forecast;
523        let json = serde_json::to_string(&action).unwrap();
524        let parsed: ChronixAction = serde_json::from_str(&json).unwrap();
525        assert_eq!(parsed, action);
526    }
527
528    #[test]
529    fn resource_serde_roundtrip() {
530        let res = ChronixResource::measurement("cpu").with_tag("host", "s1");
531        let json = serde_json::to_string(&res).unwrap();
532        let parsed: ChronixResource = serde_json::from_str(&json).unwrap();
533        assert_eq!(parsed, res);
534    }
535
536    #[test]
537    fn principal_serde_roundtrip() {
538        let p = ChronixPrincipal::new("alice").with_role("admin");
539        let json = serde_json::to_string(&p).unwrap();
540        let parsed: ChronixPrincipal = serde_json::from_str(&json).unwrap();
541        assert_eq!(parsed, p);
542    }
543
544    #[test]
545    fn namespace_builder() {
546        let ns = ChronixNamespace::new("production");
547        assert_eq!(ns.name, "production");
548    }
549
550    #[test]
551    fn namespace_to_entity() {
552        let ns = ChronixNamespace::new("staging");
553        let entity = ns.to_entity().unwrap();
554        assert!(entity.uid().to_string().contains("staging"));
555    }
556
557    #[test]
558    fn namespace_serde_roundtrip() {
559        let ns = ChronixNamespace::new("production");
560        let json = serde_json::to_string(&ns).unwrap();
561        let parsed: ChronixNamespace = serde_json::from_str(&json).unwrap();
562        assert_eq!(parsed, ns);
563    }
564
565    #[test]
566    fn resource_with_namespace_builder() {
567        let res = ChronixResource::measurement("cpu")
568            .with_namespace("production")
569            .with_tag("host", "s1");
570        assert_eq!(res.measurement, "cpu");
571        assert_eq!(res.namespace.as_deref(), Some("production"));
572        assert_eq!(res.tags.get("host").unwrap(), "s1");
573    }
574
575    #[test]
576    fn resource_with_namespace_entity_has_parent() {
577        let res = ChronixResource::measurement("cpu").with_namespace("prod");
578        let entity = res.to_entity().unwrap();
579        // Verify the entity UID is correct
580        assert!(entity.uid().to_string().contains("cpu"));
581        // Verify the namespace entity helper returns the right thing
582        let ns = res
583            .namespace_entity()
584            .expect("should have namespace entity");
585        assert!(ns.uid().to_string().contains("prod"));
586        assert!(ns.uid().to_string().contains("Namespace"));
587    }
588
589    #[test]
590    fn resource_without_namespace_has_no_parent() {
591        let res = ChronixResource::measurement("cpu");
592        let entity = res.to_entity().unwrap();
593        assert!(entity.uid().to_string().contains("cpu"));
594        assert!(res.namespace_entity().is_none());
595    }
596
597    #[test]
598    fn resource_namespace_entity_helper() {
599        let res = ChronixResource::measurement("cpu").with_namespace("staging");
600        let ns_entity = res
601            .namespace_entity()
602            .expect("should produce namespace entity");
603        assert!(ns_entity.uid().to_string().contains("staging"));
604
605        let res_no_ns = ChronixResource::measurement("cpu");
606        assert!(res_no_ns.namespace_entity().is_none());
607    }
608
609    #[test]
610    fn resource_with_namespace_serde_roundtrip() {
611        let res = ChronixResource::measurement("cpu")
612            .with_namespace("prod")
613            .with_tag("host", "s1");
614        let json = serde_json::to_string(&res).unwrap();
615        let parsed: ChronixResource = serde_json::from_str(&json).unwrap();
616        assert_eq!(parsed, res);
617        assert_eq!(parsed.namespace.as_deref(), Some("prod"));
618    }
619}