use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::LazyLock;
use cedar_policy::{Entity, EntityId, EntityTypeName, EntityUid, RestrictedExpression};
use serde::{Deserialize, Serialize};
use crate::authz::error::AuthzError;
static TYPE_ACTION: LazyLock<EntityTypeName> =
LazyLock::new(|| EntityTypeName::from_str("Chronix::Action").expect("valid type"));
static TYPE_USER: LazyLock<EntityTypeName> =
LazyLock::new(|| EntityTypeName::from_str("Chronix::User").expect("valid type"));
static TYPE_ROLE: LazyLock<EntityTypeName> =
LazyLock::new(|| EntityTypeName::from_str("Chronix::Role").expect("valid type"));
static TYPE_NAMESPACE: LazyLock<EntityTypeName> =
LazyLock::new(|| EntityTypeName::from_str("Chronix::Namespace").expect("valid type"));
static TYPE_SYSTEM: LazyLock<EntityTypeName> =
LazyLock::new(|| EntityTypeName::from_str("Chronix::System").expect("valid type"));
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChronixAction {
Read,
Write,
Delete,
ManageKeys,
ManageBackups,
ManageConfig,
ManageModels,
ManageNamespaces,
ManageNodes,
ManageRegions,
ManageCluster,
ViewCluster,
}
impl ChronixAction {
pub const ALL: &'static [Self] = &[
Self::Read,
Self::Write,
Self::Delete,
Self::ManageKeys,
Self::ManageBackups,
Self::ManageConfig,
Self::ManageModels,
Self::ManageNamespaces,
Self::ManageNodes,
Self::ManageRegions,
Self::ManageCluster,
Self::ViewCluster,
];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Read => "Read",
Self::Write => "Write",
Self::Delete => "Delete",
Self::ManageKeys => "ManageKeys",
Self::ManageBackups => "ManageBackups",
Self::ManageConfig => "ManageConfig",
Self::ManageModels => "ManageModels",
Self::ManageNamespaces => "ManageNamespaces",
Self::ManageNodes => "ManageNodes",
Self::ManageRegions => "ManageRegions",
Self::ManageCluster => "ManageCluster",
Self::ViewCluster => "ViewCluster",
}
}
#[must_use]
pub fn to_entity_uid(self) -> EntityUid {
EntityUid::from_type_name_and_id(TYPE_ACTION.clone(), EntityId::new(self.name()))
}
#[must_use]
pub const fn is_administrative(self) -> bool {
!matches!(self, Self::Read | Self::Write | Self::Delete)
}
}
impl std::fmt::Display for ChronixAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChronixNamespace {
pub name: String,
}
impl ChronixNamespace {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
pub fn to_entity(&self) -> Result<Entity, AuthzError> {
let uid =
EntityUid::from_type_name_and_id(TYPE_NAMESPACE.clone(), EntityId::new(&self.name));
let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
attrs.insert(
"name".into(),
RestrictedExpression::new_string(self.name.clone()),
);
Entity::new(uid, attrs, HashSet::new())
.map_err(|e| AuthzError::Internal(format!("Namespace entity: {e}")))
}
#[must_use]
pub fn to_entity_uid(&self) -> EntityUid {
EntityUid::from_type_name_and_id(TYPE_NAMESPACE.clone(), EntityId::new(&self.name))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChronixSystem;
impl ChronixSystem {
pub const ID: &'static str = "chronix";
pub fn to_entity() -> Result<Entity, AuthzError> {
let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
attrs.insert(
"name".into(),
RestrictedExpression::new_string(Self::ID.into()),
);
Entity::new(Self::to_entity_uid(), attrs, HashSet::new())
.map_err(|e| AuthzError::Internal(format!("System entity: {e}")))
}
#[must_use]
pub fn to_entity_uid() -> EntityUid {
EntityUid::from_type_name_and_id(TYPE_SYSTEM.clone(), EntityId::new(Self::ID))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChronixPrincipal {
pub id: String,
#[serde(default)]
pub roles: Vec<String>,
}
impl ChronixPrincipal {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
roles: Vec::new(),
}
}
#[must_use]
pub fn with_role(mut self, role: impl Into<String>) -> Self {
self.roles.push(role.into());
self
}
#[must_use]
pub fn with_roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.roles.extend(roles.into_iter().map(Into::into));
self
}
pub fn to_entity(&self) -> Result<Entity, AuthzError> {
let uid = EntityUid::from_type_name_and_id(TYPE_USER.clone(), EntityId::new(&self.id));
let parents: HashSet<EntityUid> = self
.roles
.iter()
.map(|role| EntityUid::from_type_name_and_id(TYPE_ROLE.clone(), EntityId::new(role)))
.collect();
let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
attrs.insert(
"name".into(),
RestrictedExpression::new_string(self.id.clone()),
);
Entity::new(uid, attrs, parents)
.map_err(|e| AuthzError::Internal(format!("User entity: {e}")))
}
pub fn role_entities(&self) -> Result<Vec<Entity>, AuthzError> {
self.roles
.iter()
.map(|role| {
let uid = EntityUid::from_type_name_and_id(TYPE_ROLE.clone(), EntityId::new(role));
Entity::new(uid, HashMap::new(), HashSet::new())
.map_err(|e| AuthzError::Internal(format!("Role entity: {e}")))
})
.collect()
}
#[must_use]
pub fn to_entity_uid(&self) -> EntityUid {
EntityUid::from_type_name_and_id(TYPE_USER.clone(), EntityId::new(&self.id))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Decision {
Allow,
Deny {
reasons: Vec<String>,
},
}
impl Decision {
#[must_use]
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Allow)
}
#[must_use]
pub fn is_denied(&self) -> bool {
!self.is_allowed()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_display_is_the_cedar_name() {
assert_eq!(ChronixAction::Write.to_string(), "Write");
assert_eq!(ChronixAction::ManageBackups.to_string(), "ManageBackups");
}
#[test]
fn action_to_entity_uid() {
let uid = ChronixAction::Read.to_entity_uid();
assert_eq!(uid.to_string(), r#"Chronix::Action::"Read""#);
}
#[test]
fn data_actions_are_not_administrative() {
for a in [
ChronixAction::Read,
ChronixAction::Write,
ChronixAction::Delete,
] {
assert!(!a.is_administrative(), "{a} should apply to a namespace");
}
for a in ChronixAction::ALL.iter().copied().filter(|a| {
!matches!(
a,
ChronixAction::Read | ChronixAction::Write | ChronixAction::Delete
)
}) {
assert!(a.is_administrative(), "{a} should apply to the system");
}
}
#[test]
fn namespace_to_entity() {
let entity = ChronixNamespace::new("prod").to_entity().unwrap();
assert_eq!(entity.uid().to_string(), r#"Chronix::Namespace::"prod""#);
}
#[test]
fn system_is_a_singleton() {
assert_eq!(
ChronixSystem::to_entity_uid().to_string(),
r#"Chronix::System::"chronix""#
);
}
#[test]
fn principal_roles_become_parents_and_entities() {
let p = ChronixPrincipal::new("alice").with_roles(["admin", "data-science"]);
assert_eq!(p.roles.len(), 2);
let entity = p.to_entity().unwrap();
assert_eq!(entity.uid().to_string(), r#"Chronix::User::"alice""#);
assert_eq!(p.role_entities().unwrap().len(), 2);
}
#[test]
fn decision_is_allowed_or_denied() {
assert!(Decision::Allow.is_allowed());
assert!(Decision::Deny { reasons: vec![] }.is_denied());
}
}