1use 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
18static TYPE_ACTION: LazyLock<EntityTypeName> =
22 LazyLock::new(|| EntityTypeName::from_str("Chronix::Action").expect("valid type"));
23
24static TYPE_USER: LazyLock<EntityTypeName> =
26 LazyLock::new(|| EntityTypeName::from_str("Chronix::User").expect("valid type"));
27
28static TYPE_ROLE: LazyLock<EntityTypeName> =
30 LazyLock::new(|| EntityTypeName::from_str("Chronix::Role").expect("valid type"));
31
32static TYPE_MEASUREMENT: LazyLock<EntityTypeName> =
34 LazyLock::new(|| EntityTypeName::from_str("Chronix::Measurement").expect("valid type"));
35
36static TYPE_NAMESPACE: LazyLock<EntityTypeName> =
38 LazyLock::new(|| EntityTypeName::from_str("Chronix::Namespace").expect("valid type"));
39
40pub(crate) fn role_type_name() -> &'static EntityTypeName {
42 &TYPE_ROLE
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ChronixAction {
58 Write,
60 Read,
62 Delete,
64 Admin,
66 CreateRollup,
68 Forecast,
70 DetectAnomalies,
72 Subscribe,
74
75 ManageNodes,
78 ManageRegions,
80 ViewCluster,
82 ManageCluster,
84 ManageModels,
86 ManageKeys,
88 ManageConfig,
90 ManageBackups,
92 ManageNamespaces,
94}
95
96impl ChronixAction {
97 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ChronixResource {
174 pub measurement: String,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub namespace: Option<String>,
191 #[serde(default)]
193 pub tags: BTreeMap<String, String>,
194}
195
196impl ChronixResource {
197 #[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 #[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 const MAX_TAGS: usize = 64;
220
221 #[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 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 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 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct ChronixNamespace {
317 pub name: String,
319}
320
321impl ChronixNamespace {
322 #[must_use]
324 pub fn new(name: impl Into<String>) -> Self {
325 Self { name: name.into() }
326 }
327
328 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354pub struct ChronixPrincipal {
355 pub id: String,
357 #[serde(default)]
359 pub roles: Vec<String>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub namespace: Option<String>,
365}
366
367impl ChronixPrincipal {
368 #[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 #[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 #[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 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432pub enum Decision {
433 Allow,
435 Deny {
437 reasons: Vec<String>,
439 },
440}
441
442impl Decision {
443 #[must_use]
445 pub fn is_allowed(&self) -> bool {
446 matches!(self, Self::Allow)
447 }
448
449 #[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 assert!(entity.uid().to_string().contains("cpu"));
581 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}