1use 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
26static TYPE_ACTION: LazyLock<EntityTypeName> =
30 LazyLock::new(|| EntityTypeName::from_str("Chronix::Action").expect("valid type"));
31
32static TYPE_USER: LazyLock<EntityTypeName> =
34 LazyLock::new(|| EntityTypeName::from_str("Chronix::User").expect("valid type"));
35
36static TYPE_ROLE: LazyLock<EntityTypeName> =
38 LazyLock::new(|| EntityTypeName::from_str("Chronix::Role").expect("valid type"));
39
40static TYPE_NAMESPACE: LazyLock<EntityTypeName> =
42 LazyLock::new(|| EntityTypeName::from_str("Chronix::Namespace").expect("valid type"));
43
44static TYPE_SYSTEM: LazyLock<EntityTypeName> =
46 LazyLock::new(|| EntityTypeName::from_str("Chronix::System").expect("valid type"));
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ChronixAction {
66 Read,
69 Write,
71 Delete,
73
74 ManageKeys,
77 ManageBackups,
79 ManageConfig,
81 ManageModels,
83 ManageNamespaces,
85 ManageNodes,
87 ManageRegions,
89 ManageCluster,
91 ViewCluster,
93}
94
95impl ChronixAction {
96 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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct ChronixNamespace {
169 pub name: String,
171}
172
173impl ChronixNamespace {
174 #[must_use]
176 pub fn new(name: impl Into<String>) -> Self {
177 Self { name: name.into() }
178 }
179
180 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct ChronixSystem;
216
217impl ChronixSystem {
218 pub const ID: &'static str = "chronix";
220
221 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ChronixPrincipal {
254 pub id: String,
256 #[serde(default)]
258 pub roles: Vec<String>,
259}
260
261impl ChronixPrincipal {
262 #[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 #[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 #[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 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub enum Decision {
345 Allow,
347 Deny {
349 reasons: Vec<String>,
351 },
352}
353
354impl Decision {
355 #[must_use]
357 pub fn is_allowed(&self) -> bool {
358 matches!(self, Self::Allow)
359 }
360
361 #[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}