use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
macro_rules! define_sod_policy {
($name:ident) => {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct $name {
/// Operator-facing identifier; uniqueness key for the store's add
pub name: String,
pub roles: HashSet<String>,
pub cardinality: usize,
}
impl $name {
#[must_use]
pub fn new(
name: impl Into<String>,
roles: HashSet<String>,
cardinality: usize,
) -> Self {
Self {
name: name.into(),
roles,
cardinality: cardinality.max(1),
}
}
#[must_use]
pub fn validate(&self, roles: &[String]) -> bool {
let count = roles.iter().filter(|r| self.roles.contains(*r)).count();
count < self.cardinality
}
}
};
}
define_sod_policy!(SsdPolicy);
define_sod_policy!(DsdPolicy);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardinalityConstraint {
pub role_name: String,
pub max_subjects: usize,
}
impl CardinalityConstraint {
#[must_use]
pub fn new(role_name: impl Into<String>, max_subjects: usize) -> Self {
Self {
role_name: role_name.into(),
max_subjects: max_subjects.max(1),
}
}
#[must_use]
pub fn validate(&self, current_count: usize) -> bool {
current_count < self.max_subjects
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrerequisiteConstraint {
pub role_name: String,
pub requires: String,
}
impl PrerequisiteConstraint {
#[must_use]
pub fn new(role_name: impl Into<String>, requires: impl Into<String>) -> Self {
Self {
role_name: role_name.into(),
requires: requires.into(),
}
}
#[must_use]
pub fn validate(&self, assigned_roles: &[String]) -> bool {
assigned_roles.contains(&self.requires)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalConstraint {
pub role_name: String,
pub valid_from: chrono::DateTime<chrono::Utc>,
pub valid_until: chrono::DateTime<chrono::Utc>,
}
impl TemporalConstraint {
pub fn new(
role_name: impl Into<String>,
valid_from: chrono::DateTime<chrono::Utc>,
valid_until: chrono::DateTime<chrono::Utc>,
) -> Result<Self> {
if valid_from >= valid_until {
return Err(crate::error::KirinoError::Validation(format!(
"TemporalConstraint: valid_from ({}) must be before valid_until ({})",
valid_from, valid_until
))
.into());
}
Ok(Self {
role_name: role_name.into(),
valid_from,
valid_until,
})
}
#[must_use]
pub fn is_valid(&self) -> bool {
let now = chrono::Utc::now();
now >= self.valid_from && now <= self.valid_until
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssd_policy_allows() {
let policy = SsdPolicy::new(
"admin_auditor_exclusive",
["admin".to_string(), "auditor".to_string()].into(),
2,
);
assert!(policy.validate(&["admin".to_string()]));
assert!(policy.validate(&["viewer".to_string()]));
}
#[test]
fn test_ssd_policy_rejects() {
let policy = SsdPolicy::new(
"admin_auditor_exclusive",
["admin".to_string(), "auditor".to_string()].into(),
2,
);
assert!(!policy.validate(&["admin".to_string(), "auditor".to_string()]));
}
#[test]
fn test_dsd_policy() {
let policy = DsdPolicy::new(
"ops_audit_session",
["operator".to_string(), "auditor".to_string()].into(),
2,
);
assert!(policy.validate(&["operator".to_string()]));
assert!(!policy.validate(&["operator".to_string(), "auditor".to_string()]));
}
#[test]
fn test_cardinality_constraint() {
let c = CardinalityConstraint::new("admin", 2);
assert!(c.validate(1));
assert!(c.validate(0));
assert!(!c.validate(2));
}
#[test]
fn test_prerequisite_constraint() {
let c = PrerequisiteConstraint::new("admin", "operator");
assert!(!c.validate(&["viewer".to_string()]));
assert!(c.validate(&["operator".to_string(), "viewer".to_string()]));
}
#[test]
fn test_temporal_constraint() {
let now = chrono::Utc::now();
let valid = TemporalConstraint::new(
"temp_role",
now - chrono::Duration::hours(1),
now + chrono::Duration::hours(1),
)
.unwrap();
assert!(valid.is_valid());
let expired = TemporalConstraint::new(
"temp_role",
now - chrono::Duration::hours(2),
now - chrono::Duration::hours(1),
)
.unwrap();
assert!(!expired.is_valid());
}
#[test]
fn test_temporal_constraint_inverted_dates_rejected() {
let now = chrono::Utc::now();
let result = TemporalConstraint::new(
"temp_role",
now + chrono::Duration::hours(1),
now - chrono::Duration::hours(1),
);
assert!(result.is_err());
}
}