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. Everything here has a producer in `chronixd` and a line in
5//! [`chronix.cedarschema`](../chronix.cedarschema); nothing is modelled that
6//! no request carries.
7//!
8//! That was not always true. The model once described measurements with tag
9//! constraints, seventeen actions and a super-action, and `chronixd` issued
10//! exactly two of them — so a policy written against a measurement, or
11//! against `ManageModels`, was never consulted by anything. A model wider
12//! than its enforcement reads as protection.
13//!
14//! Entity type names are parsed once and cached via `LazyLock` to avoid
15//! per-request `from_str` + `expect()` overhead on the hot path.
16
17use std::collections::{HashMap, HashSet};
18use std::str::FromStr;
19use std::sync::LazyLock;
20
21use cedar_policy::{Entity, EntityId, EntityTypeName, EntityUid, RestrictedExpression};
22use serde::{Deserialize, Serialize};
23
24use crate::authz::error::AuthzError;
25
26// ── Cached entity type names ────────────────────────────────────────
27
28/// Pre-parsed entity type name for `Chronix::Action`.
29static TYPE_ACTION: LazyLock<EntityTypeName> =
30    LazyLock::new(|| EntityTypeName::from_str("Chronix::Action").expect("valid type"));
31
32/// Pre-parsed entity type name for `Chronix::User`.
33static TYPE_USER: LazyLock<EntityTypeName> =
34    LazyLock::new(|| EntityTypeName::from_str("Chronix::User").expect("valid type"));
35
36/// Pre-parsed entity type name for `Chronix::Role`.
37static TYPE_ROLE: LazyLock<EntityTypeName> =
38    LazyLock::new(|| EntityTypeName::from_str("Chronix::Role").expect("valid type"));
39
40/// Pre-parsed entity type name for `Chronix::Namespace`.
41static TYPE_NAMESPACE: LazyLock<EntityTypeName> =
42    LazyLock::new(|| EntityTypeName::from_str("Chronix::Namespace").expect("valid type"));
43
44/// Pre-parsed entity type name for `Chronix::System`.
45static TYPE_SYSTEM: LazyLock<EntityTypeName> =
46    LazyLock::new(|| EntityTypeName::from_str("Chronix::System").expect("valid type"));
47
48// ── Actions ─────────────────────────────────────────────────────────
49
50/// An action `chronixd` asks about.
51///
52/// Every variant is issued by a request path, and every action in the schema
53/// is a variant here — the two lists are checked against each other by
54/// `the_schema_describes_exactly_the_actions_we_issue`. An action nothing
55/// issues is a policy clause that silently never fires, which is the whole
56/// failure mode this enum exists to avoid.
57///
58/// `Admin` is **not** here on purpose. It is an action *group* in the schema:
59/// the granular administrative actions are members of it, so
60/// `action in Chronix::Action::"Admin"` grants all of them while
61/// `action == Chronix::Action::"ManageBackups"` grants one. Nothing requests
62/// the group, so nothing can be granted by naming it as an equality.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ChronixAction {
66    /// Read data from a namespace (`GET`, `HEAD`, `OPTIONS`, and every query
67    /// RPC).
68    Read,
69    /// Write data to a namespace, or change something in it.
70    Write,
71    /// Delete data from a namespace.
72    Delete,
73
74    // ── administrative actions, all members of the `Admin` group ────
75    /// Create, list and revoke API keys.
76    ManageKeys,
77    /// Take, verify and restore backups.
78    ManageBackups,
79    /// Change runtime configuration, such as the log level.
80    ManageConfig,
81    /// List, inspect, delete and retrain analytics models.
82    ManageModels,
83    /// Create, delete and inspect namespaces.
84    ManageNamespaces,
85    /// Register, deregister and decommission cluster nodes.
86    ManageNodes,
87    /// Create regions and drive their state transitions.
88    ManageRegions,
89    /// Trigger cluster rebalancing.
90    ManageCluster,
91    /// Read cluster topology, routing and health.
92    ViewCluster,
93}
94
95impl ChronixAction {
96    /// Every action, in schema order. The inventory the schema is checked
97    /// against.
98    pub const ALL: &'static [Self] = &[
99        Self::Read,
100        Self::Write,
101        Self::Delete,
102        Self::ManageKeys,
103        Self::ManageBackups,
104        Self::ManageConfig,
105        Self::ManageModels,
106        Self::ManageNamespaces,
107        Self::ManageNodes,
108        Self::ManageRegions,
109        Self::ManageCluster,
110        Self::ViewCluster,
111    ];
112
113    /// The Cedar action name, which is also its `Display`.
114    #[must_use]
115    pub const fn name(self) -> &'static str {
116        match self {
117            Self::Read => "Read",
118            Self::Write => "Write",
119            Self::Delete => "Delete",
120            Self::ManageKeys => "ManageKeys",
121            Self::ManageBackups => "ManageBackups",
122            Self::ManageConfig => "ManageConfig",
123            Self::ManageModels => "ManageModels",
124            Self::ManageNamespaces => "ManageNamespaces",
125            Self::ManageNodes => "ManageNodes",
126            Self::ManageRegions => "ManageRegions",
127            Self::ManageCluster => "ManageCluster",
128            Self::ViewCluster => "ViewCluster",
129        }
130    }
131
132    /// Convert to the Cedar action entity UID.
133    #[must_use]
134    pub fn to_entity_uid(self) -> EntityUid {
135        EntityUid::from_type_name_and_id(TYPE_ACTION.clone(), EntityId::new(self.name()))
136    }
137
138    /// Whether this action's resource is the server rather than a namespace.
139    ///
140    /// The split is the schema's: an administrative action `appliesTo`
141    /// `System`, a data action `appliesTo` `Namespace`. Asking the wrong one
142    /// is a validation error at policy-load time rather than a decision that
143    /// silently never matches.
144    #[must_use]
145    pub const fn is_administrative(self) -> bool {
146        !matches!(self, Self::Read | Self::Write | Self::Delete)
147    }
148}
149
150impl std::fmt::Display for ChronixAction {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.write_str(self.name())
153    }
154}
155
156// ── Namespace resource ──────────────────────────────────────────────
157
158/// A Chronix namespace — the resource of every data-plane decision.
159///
160/// ```cedar
161/// permit(
162///   principal in Chronix::Role::"ns_admin",
163///   action,
164///   resource == Chronix::Namespace::"production"
165/// );
166/// ```
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct ChronixNamespace {
169    /// Namespace identifier.
170    pub name: String,
171}
172
173impl ChronixNamespace {
174    /// Create a namespace resource.
175    #[must_use]
176    pub fn new(name: impl Into<String>) -> Self {
177        Self { name: name.into() }
178    }
179
180    /// Convert to a Cedar entity.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`AuthzError::Internal`] if Cedar rejects the entity.
185    pub fn to_entity(&self) -> Result<Entity, AuthzError> {
186        let uid =
187            EntityUid::from_type_name_and_id(TYPE_NAMESPACE.clone(), EntityId::new(&self.name));
188
189        let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
190        attrs.insert(
191            "name".into(),
192            RestrictedExpression::new_string(self.name.clone()),
193        );
194
195        Entity::new(uid, attrs, HashSet::new())
196            .map_err(|e| AuthzError::Internal(format!("Namespace entity: {e}")))
197    }
198
199    /// Convert to the Cedar entity UID.
200    #[must_use]
201    pub fn to_entity_uid(&self) -> EntityUid {
202        EntityUid::from_type_name_and_id(TYPE_NAMESPACE.clone(), EntityId::new(&self.name))
203    }
204}
205
206// ── System resource ─────────────────────────────────────────────────
207
208/// The server itself — the resource of every administrative decision.
209///
210/// A singleton, so a policy names it as `Chronix::System::"chronix"` or
211/// leaves `resource` unconstrained. It replaced a `Measurement` entity called
212/// `"__system__"`, which validated against nothing and read as a measurement
213/// somebody might also have data in.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct ChronixSystem;
216
217impl ChronixSystem {
218    /// The singleton's entity id.
219    pub const ID: &'static str = "chronix";
220
221    /// Convert to a Cedar entity.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`AuthzError::Internal`] if Cedar rejects the entity.
226    pub fn to_entity() -> Result<Entity, AuthzError> {
227        let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
228        attrs.insert(
229            "name".into(),
230            RestrictedExpression::new_string(Self::ID.into()),
231        );
232        Entity::new(Self::to_entity_uid(), attrs, HashSet::new())
233            .map_err(|e| AuthzError::Internal(format!("System entity: {e}")))
234    }
235
236    /// Convert to the Cedar entity UID.
237    #[must_use]
238    pub fn to_entity_uid() -> EntityUid {
239        EntityUid::from_type_name_and_id(TYPE_SYSTEM.clone(), EntityId::new(Self::ID))
240    }
241}
242
243// ── Principal ───────────────────────────────────────────────────────
244
245/// A Chronix principal (authenticated identity).
246///
247/// Built in one place — `chronixd`'s authentication middleware, from the
248/// credential — so the roles a policy matches on are the same set whichever
249/// gate asks. They used to be resolved inside the admin gate alone, so the
250/// *same* role granted an administrative operation and nothing in a
251/// namespace policy, and an API key had no roles at all.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ChronixPrincipal {
254    /// The principal identifier (an API key name, a JWT subject, a cert CN).
255    pub id: String,
256    /// Roles assigned to this principal, as `Chronix::Role` parents.
257    #[serde(default)]
258    pub roles: Vec<String>,
259}
260
261impl ChronixPrincipal {
262    /// Create a new principal.
263    #[must_use]
264    pub fn new(id: impl Into<String>) -> Self {
265        Self {
266            id: id.into(),
267            roles: Vec::new(),
268        }
269    }
270
271    /// Add a role to the principal.
272    #[must_use]
273    pub fn with_role(mut self, role: impl Into<String>) -> Self {
274        self.roles.push(role.into());
275        self
276    }
277
278    /// Add several roles.
279    #[must_use]
280    pub fn with_roles<I, S>(mut self, roles: I) -> Self
281    where
282        I: IntoIterator<Item = S>,
283        S: Into<String>,
284    {
285        self.roles.extend(roles.into_iter().map(Into::into));
286        self
287    }
288
289    /// Convert to a Cedar entity (roles become parent entities).
290    ///
291    /// # Errors
292    ///
293    /// Returns [`AuthzError::Internal`] if Cedar rejects the entity.
294    pub fn to_entity(&self) -> Result<Entity, AuthzError> {
295        let uid = EntityUid::from_type_name_and_id(TYPE_USER.clone(), EntityId::new(&self.id));
296
297        let parents: HashSet<EntityUid> = self
298            .roles
299            .iter()
300            .map(|role| EntityUid::from_type_name_and_id(TYPE_ROLE.clone(), EntityId::new(role)))
301            .collect();
302
303        let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
304        attrs.insert(
305            "name".into(),
306            RestrictedExpression::new_string(self.id.clone()),
307        );
308
309        Entity::new(uid, attrs, parents)
310            .map_err(|e| AuthzError::Internal(format!("User entity: {e}")))
311    }
312
313    /// The role entities this principal claims membership of.
314    ///
315    /// Cedar needs them in the entity set for `principal in
316    /// Chronix::Role::"…"` to resolve; a role with no entity is a membership
317    /// that silently does not hold.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`AuthzError::Internal`] if Cedar rejects an entity.
322    pub fn role_entities(&self) -> Result<Vec<Entity>, AuthzError> {
323        self.roles
324            .iter()
325            .map(|role| {
326                let uid = EntityUid::from_type_name_and_id(TYPE_ROLE.clone(), EntityId::new(role));
327                Entity::new(uid, HashMap::new(), HashSet::new())
328                    .map_err(|e| AuthzError::Internal(format!("Role entity: {e}")))
329            })
330            .collect()
331    }
332
333    /// Convert to the Cedar entity UID.
334    #[must_use]
335    pub fn to_entity_uid(&self) -> EntityUid {
336        EntityUid::from_type_name_and_id(TYPE_USER.clone(), EntityId::new(&self.id))
337    }
338}
339
340// ── Decision ────────────────────────────────────────────────────────
341
342/// The result of an authorization decision.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub enum Decision {
345    /// Access is allowed.
346    Allow,
347    /// Access is denied.
348    Deny {
349        /// Human-readable reasons for the denial.
350        reasons: Vec<String>,
351    },
352}
353
354impl Decision {
355    /// Returns `true` if the decision is `Allow`.
356    #[must_use]
357    pub fn is_allowed(&self) -> bool {
358        matches!(self, Self::Allow)
359    }
360
361    /// Returns `true` if the decision is `Deny`.
362    #[must_use]
363    pub fn is_denied(&self) -> bool {
364        !self.is_allowed()
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn action_display_is_the_cedar_name() {
374        assert_eq!(ChronixAction::Write.to_string(), "Write");
375        assert_eq!(ChronixAction::ManageBackups.to_string(), "ManageBackups");
376    }
377
378    #[test]
379    fn action_to_entity_uid() {
380        let uid = ChronixAction::Read.to_entity_uid();
381        assert_eq!(uid.to_string(), r#"Chronix::Action::"Read""#);
382    }
383
384    #[test]
385    fn data_actions_are_not_administrative() {
386        for a in [
387            ChronixAction::Read,
388            ChronixAction::Write,
389            ChronixAction::Delete,
390        ] {
391            assert!(!a.is_administrative(), "{a} should apply to a namespace");
392        }
393        for a in ChronixAction::ALL.iter().copied().filter(|a| {
394            !matches!(
395                a,
396                ChronixAction::Read | ChronixAction::Write | ChronixAction::Delete
397            )
398        }) {
399            assert!(a.is_administrative(), "{a} should apply to the system");
400        }
401    }
402
403    #[test]
404    fn namespace_to_entity() {
405        let entity = ChronixNamespace::new("prod").to_entity().unwrap();
406        assert_eq!(entity.uid().to_string(), r#"Chronix::Namespace::"prod""#);
407    }
408
409    #[test]
410    fn system_is_a_singleton() {
411        assert_eq!(
412            ChronixSystem::to_entity_uid().to_string(),
413            r#"Chronix::System::"chronix""#
414        );
415    }
416
417    #[test]
418    fn principal_roles_become_parents_and_entities() {
419        let p = ChronixPrincipal::new("alice").with_roles(["admin", "data-science"]);
420        assert_eq!(p.roles.len(), 2);
421        let entity = p.to_entity().unwrap();
422        assert_eq!(entity.uid().to_string(), r#"Chronix::User::"alice""#);
423        assert_eq!(p.role_entities().unwrap().len(), 2);
424    }
425
426    #[test]
427    fn decision_is_allowed_or_denied() {
428        assert!(Decision::Allow.is_allowed());
429        assert!(Decision::Deny { reasons: vec![] }.is_denied());
430    }
431}